Skip to content

Commit 68c4f28

Browse files
authored
feat(clickup): webhook triggers, hierarchy selectors, and Docs knowledge-base connector (#5708)
* feat(clickup): webhook triggers with auto-managed subscriptions + hierarchy selectors * feat(clickup): KB connector (Docs v3), selector-route hardening, subblock migrations, registry-check regex fix * test(clickup): webhook provider handler tests + redact create-response secret in error logs * fix(clickup-connector): trim final page to maxDocs cap with precise listingCapped semantics * chore(clickup): lint formatting * fix(clickup): restore list-op location requiredness, split listSpaceId migration target, surface failed webhook rollback * fix(clickup): clickup.lists selector accepts listSpaceId context like clickup.folders * fix(clickup): integer-only maxDocs and location filters, depth-aware doc headings, most-specific-location hints
1 parent ff1d061 commit 68c4f28

57 files changed

Lines changed: 3624 additions & 55 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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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 { validateAlphanumericId } from '@/lib/core/security/input-validation'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
10+
import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared'
11+
12+
const logger = createLogger('ClickUpFoldersAPI')
13+
14+
export const dynamic = 'force-dynamic'
15+
16+
interface ClickUpNamedResource {
17+
id: string | number
18+
name?: string
19+
}
20+
21+
export const POST = withRouteHandler(async (request: NextRequest) => {
22+
try {
23+
const requestId = generateRequestId()
24+
const parsed = await parseRequest(clickupFoldersSelectorContract, request, {})
25+
if (!parsed.success) return parsed.response
26+
const { credential, workflowId, spaceId } = parsed.data.body
27+
28+
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId')
29+
if (!spaceIdValidation.isValid) {
30+
logger.error('Invalid spaceId', { error: spaceIdValidation.error })
31+
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
32+
}
33+
34+
const authz = await authorizeCredentialUse(request, {
35+
credentialId: credential,
36+
workflowId,
37+
})
38+
if (!authz.ok || !authz.credentialOwnerUserId) {
39+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
40+
}
41+
42+
const accessToken = await refreshAccessTokenIfNeeded(
43+
credential,
44+
authz.credentialOwnerUserId,
45+
requestId
46+
)
47+
if (!accessToken) {
48+
logger.error('Failed to get access token', {
49+
credentialId: credential,
50+
userId: authz.credentialOwnerUserId,
51+
})
52+
return NextResponse.json(
53+
{ error: 'Could not retrieve access token', authRequired: true },
54+
{ status: 401 }
55+
)
56+
}
57+
58+
const response = await fetch(
59+
`${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(spaceId)}/folder`,
60+
{
61+
headers: {
62+
Authorization: clickupAuthorizationHeader(accessToken),
63+
Accept: 'application/json',
64+
},
65+
}
66+
)
67+
68+
if (!response.ok) {
69+
const errorData = await response.json().catch(() => ({}))
70+
logger.error('Failed to fetch ClickUp folders', {
71+
status: response.status,
72+
error: errorData,
73+
})
74+
return NextResponse.json(
75+
{ error: 'Failed to fetch ClickUp folders' },
76+
{ status: response.status }
77+
)
78+
}
79+
80+
const data = (await response.json().catch(() => ({}))) as {
81+
folders?: ClickUpNamedResource[]
82+
}
83+
const folders = (Array.isArray(data.folders) ? data.folders : []).map((item) => ({
84+
id: String(item.id),
85+
name: item.name || `Folder ${item.id}`,
86+
}))
87+
88+
return NextResponse.json({ folders })
89+
} catch (error) {
90+
logger.error('Error fetching ClickUp folders', error)
91+
return NextResponse.json({ error: 'Failed to fetch ClickUp folders' }, { status: 500 })
92+
}
93+
})
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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 { validateAlphanumericId } from '@/lib/core/security/input-validation'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
10+
import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared'
11+
12+
const logger = createLogger('ClickUpListsAPI')
13+
14+
export const dynamic = 'force-dynamic'
15+
16+
interface ClickUpNamedResource {
17+
id: string | number
18+
name?: string
19+
}
20+
21+
export const POST = withRouteHandler(async (request: NextRequest) => {
22+
try {
23+
const requestId = generateRequestId()
24+
const parsed = await parseRequest(clickupListsSelectorContract, request, {})
25+
if (!parsed.success) return parsed.response
26+
const { credential, workflowId, folderId, spaceId } = parsed.data.body
27+
28+
if (folderId?.trim()) {
29+
const folderIdValidation = validateAlphanumericId(folderId.trim(), 'folderId')
30+
if (!folderIdValidation.isValid) {
31+
logger.error('Invalid folderId', { error: folderIdValidation.error })
32+
return NextResponse.json({ error: folderIdValidation.error }, { status: 400 })
33+
}
34+
}
35+
36+
if (spaceId?.trim()) {
37+
const spaceIdValidation = validateAlphanumericId(spaceId.trim(), 'spaceId')
38+
if (!spaceIdValidation.isValid) {
39+
logger.error('Invalid spaceId', { error: spaceIdValidation.error })
40+
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
41+
}
42+
}
43+
44+
const authz = await authorizeCredentialUse(request, {
45+
credentialId: credential,
46+
workflowId,
47+
})
48+
if (!authz.ok || !authz.credentialOwnerUserId) {
49+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
50+
}
51+
52+
const accessToken = await refreshAccessTokenIfNeeded(
53+
credential,
54+
authz.credentialOwnerUserId,
55+
requestId
56+
)
57+
if (!accessToken) {
58+
logger.error('Failed to get access token', {
59+
credentialId: credential,
60+
userId: authz.credentialOwnerUserId,
61+
})
62+
return NextResponse.json(
63+
{ error: 'Could not retrieve access token', authRequired: true },
64+
{ status: 401 }
65+
)
66+
}
67+
68+
const response = await fetch(
69+
folderId?.trim()
70+
? `${CLICKUP_API_BASE_URL}/folder/${encodeURIComponent(folderId.trim())}/list`
71+
: `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent((spaceId ?? '').trim())}/list`,
72+
{
73+
headers: {
74+
Authorization: clickupAuthorizationHeader(accessToken),
75+
Accept: 'application/json',
76+
},
77+
}
78+
)
79+
80+
if (!response.ok) {
81+
const errorData = await response.json().catch(() => ({}))
82+
logger.error('Failed to fetch ClickUp lists', {
83+
status: response.status,
84+
error: errorData,
85+
})
86+
return NextResponse.json(
87+
{ error: 'Failed to fetch ClickUp lists' },
88+
{ status: response.status }
89+
)
90+
}
91+
92+
const data = (await response.json().catch(() => ({}))) as {
93+
lists?: ClickUpNamedResource[]
94+
}
95+
const lists = (Array.isArray(data.lists) ? data.lists : []).map((item) => ({
96+
id: String(item.id),
97+
name: item.name || `List ${item.id}`,
98+
}))
99+
100+
return NextResponse.json({ lists })
101+
} catch (error) {
102+
logger.error('Error fetching ClickUp lists', error)
103+
return NextResponse.json({ error: 'Failed to fetch ClickUp lists' }, { status: 500 })
104+
}
105+
})
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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 { validateAlphanumericId } from '@/lib/core/security/input-validation'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
10+
import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared'
11+
12+
const logger = createLogger('ClickUpSpacesAPI')
13+
14+
export const dynamic = 'force-dynamic'
15+
16+
interface ClickUpNamedResource {
17+
id: string | number
18+
name?: string
19+
}
20+
21+
export const POST = withRouteHandler(async (request: NextRequest) => {
22+
try {
23+
const requestId = generateRequestId()
24+
const parsed = await parseRequest(clickupSpacesSelectorContract, request, {})
25+
if (!parsed.success) return parsed.response
26+
const { credential, workflowId, teamId } = parsed.data.body
27+
28+
const teamIdValidation = validateAlphanumericId(teamId, 'teamId')
29+
if (!teamIdValidation.isValid) {
30+
logger.error('Invalid teamId', { error: teamIdValidation.error })
31+
return NextResponse.json({ error: teamIdValidation.error }, { status: 400 })
32+
}
33+
34+
const authz = await authorizeCredentialUse(request, {
35+
credentialId: credential,
36+
workflowId,
37+
})
38+
if (!authz.ok || !authz.credentialOwnerUserId) {
39+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
40+
}
41+
42+
const accessToken = await refreshAccessTokenIfNeeded(
43+
credential,
44+
authz.credentialOwnerUserId,
45+
requestId
46+
)
47+
if (!accessToken) {
48+
logger.error('Failed to get access token', {
49+
credentialId: credential,
50+
userId: authz.credentialOwnerUserId,
51+
})
52+
return NextResponse.json(
53+
{ error: 'Could not retrieve access token', authRequired: true },
54+
{ status: 401 }
55+
)
56+
}
57+
58+
const response = await fetch(
59+
`${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(teamId)}/space`,
60+
{
61+
headers: {
62+
Authorization: clickupAuthorizationHeader(accessToken),
63+
Accept: 'application/json',
64+
},
65+
}
66+
)
67+
68+
if (!response.ok) {
69+
const errorData = await response.json().catch(() => ({}))
70+
logger.error('Failed to fetch ClickUp spaces', {
71+
status: response.status,
72+
error: errorData,
73+
})
74+
return NextResponse.json(
75+
{ error: 'Failed to fetch ClickUp spaces' },
76+
{ status: response.status }
77+
)
78+
}
79+
80+
const data = (await response.json().catch(() => ({}))) as {
81+
spaces?: ClickUpNamedResource[]
82+
}
83+
const spaces = (Array.isArray(data.spaces) ? data.spaces : []).map((item) => ({
84+
id: String(item.id),
85+
name: item.name || `Space ${item.id}`,
86+
}))
87+
88+
return NextResponse.json({ spaces })
89+
} catch (error) {
90+
logger.error('Error fetching ClickUp spaces', error)
91+
return NextResponse.json({ error: 'Failed to fetch ClickUp spaces' }, { status: 500 })
92+
}
93+
})
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 { 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+
try {
22+
const requestId = generateRequestId()
23+
const parsed = await parseRequest(clickupWorkspacesSelectorContract, request, {})
24+
if (!parsed.success) return parsed.response
25+
const { credential, workflowId } = parsed.data.body
26+
27+
const authz = await authorizeCredentialUse(request, {
28+
credentialId: credential,
29+
workflowId,
30+
})
31+
if (!authz.ok || !authz.credentialOwnerUserId) {
32+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
33+
}
34+
35+
const accessToken = await refreshAccessTokenIfNeeded(
36+
credential,
37+
authz.credentialOwnerUserId,
38+
requestId
39+
)
40+
if (!accessToken) {
41+
logger.error('Failed to get access token', {
42+
credentialId: credential,
43+
userId: authz.credentialOwnerUserId,
44+
})
45+
return NextResponse.json(
46+
{ error: 'Could not retrieve access token', authRequired: true },
47+
{ status: 401 }
48+
)
49+
}
50+
51+
const response = await fetch(`${CLICKUP_API_BASE_URL}/team`, {
52+
headers: {
53+
Authorization: clickupAuthorizationHeader(accessToken),
54+
Accept: 'application/json',
55+
},
56+
})
57+
58+
if (!response.ok) {
59+
const errorData = await response.json().catch(() => ({}))
60+
logger.error('Failed to fetch ClickUp workspaces', {
61+
status: response.status,
62+
error: errorData,
63+
})
64+
return NextResponse.json(
65+
{ error: 'Failed to fetch ClickUp workspaces' },
66+
{ status: response.status }
67+
)
68+
}
69+
70+
const data = (await response.json().catch(() => ({}))) as { teams?: ClickUpTeam[] }
71+
const workspaces = (Array.isArray(data.teams) ? data.teams : []).map((team) => ({
72+
id: String(team.id),
73+
name: team.name || `Workspace ${team.id}`,
74+
}))
75+
76+
return NextResponse.json({ workspaces })
77+
} catch (error) {
78+
logger.error('Error fetching ClickUp workspaces', error)
79+
return NextResponse.json({ error: 'Failed to fetch ClickUp workspaces' }, { status: 500 })
80+
}
81+
})

0 commit comments

Comments
 (0)