Skip to content

Commit b127bb4

Browse files
committed
feat(atlassian): add many more triggers and tools for jsm, jira, and confluence
1 parent 01577a1 commit b127bb4

File tree

172 files changed

+19523
-232
lines changed

Some content is hidden

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

172 files changed

+19523
-232
lines changed

apps/docs/content/docs/en/tools/confluence.mdx

Lines changed: 157 additions & 48 deletions
Large diffs are not rendered by default.

apps/docs/content/docs/en/tools/jira_service_management.mdx

Lines changed: 506 additions & 19 deletions
Large diffs are not rendered by default.

apps/sim/app/api/tools/confluence/blogposts/route.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,45 @@ const createBlogPostSchema = z.object({
3838
status: z.enum(['current', 'draft']).optional(),
3939
})
4040

41+
const updateBlogPostSchema = z
42+
.object({
43+
domain: z.string().min(1, 'Domain is required'),
44+
accessToken: z.string().min(1, 'Access token is required'),
45+
cloudId: z.string().optional(),
46+
blogPostId: z.string().min(1, 'Blog post ID is required'),
47+
title: z.string().optional(),
48+
content: z.string().optional(),
49+
status: z.enum(['current', 'draft']).optional(),
50+
})
51+
.refine(
52+
(data) => {
53+
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
54+
return validation.isValid
55+
},
56+
(data) => {
57+
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
58+
return { message: validation.error || 'Invalid blog post ID', path: ['blogPostId'] }
59+
}
60+
)
61+
62+
const deleteBlogPostSchema = z
63+
.object({
64+
domain: z.string().min(1, 'Domain is required'),
65+
accessToken: z.string().min(1, 'Access token is required'),
66+
cloudId: z.string().optional(),
67+
blogPostId: z.string().min(1, 'Blog post ID is required'),
68+
})
69+
.refine(
70+
(data) => {
71+
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
72+
return validation.isValid
73+
},
74+
(data) => {
75+
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
76+
return { message: validation.error || 'Invalid blog post ID', path: ['blogPostId'] }
77+
}
78+
)
79+
4180
/**
4281
* List all blog posts or get a specific blog post
4382
*/
@@ -283,3 +322,174 @@ export async function POST(request: NextRequest) {
283322
)
284323
}
285324
}
325+
326+
/**
327+
* Update a blog post
328+
*/
329+
export async function PUT(request: NextRequest) {
330+
try {
331+
const auth = await checkSessionOrInternalAuth(request)
332+
if (!auth.success || !auth.userId) {
333+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
334+
}
335+
336+
const body = await request.json()
337+
338+
const validation = updateBlogPostSchema.safeParse(body)
339+
if (!validation.success) {
340+
const firstError = validation.error.errors[0]
341+
return NextResponse.json({ error: firstError.message }, { status: 400 })
342+
}
343+
344+
const {
345+
domain,
346+
accessToken,
347+
cloudId: providedCloudId,
348+
blogPostId,
349+
title,
350+
content,
351+
status,
352+
} = validation.data
353+
354+
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
355+
356+
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
357+
if (!cloudIdValidation.isValid) {
358+
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
359+
}
360+
361+
const blogPostUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}`
362+
363+
const currentResponse = await fetch(blogPostUrl, {
364+
headers: {
365+
Accept: 'application/json',
366+
Authorization: `Bearer ${accessToken}`,
367+
},
368+
})
369+
370+
if (!currentResponse.ok) {
371+
const errorData = await currentResponse.json().catch(() => null)
372+
const errorMessage =
373+
errorData?.message || `Failed to fetch blog post for update (${currentResponse.status})`
374+
return NextResponse.json({ error: errorMessage }, { status: currentResponse.status })
375+
}
376+
377+
const currentPost = await currentResponse.json()
378+
const currentVersion = currentPost.version.number
379+
380+
const updateBody: Record<string, unknown> = {
381+
id: blogPostId,
382+
version: {
383+
number: currentVersion + 1,
384+
message: 'Updated via Sim',
385+
},
386+
status: status || currentPost.status || 'current',
387+
title: title || currentPost.title,
388+
}
389+
390+
if (content) {
391+
updateBody.body = {
392+
representation: 'storage',
393+
value: content,
394+
}
395+
}
396+
397+
const response = await fetch(blogPostUrl, {
398+
method: 'PUT',
399+
headers: {
400+
Accept: 'application/json',
401+
'Content-Type': 'application/json',
402+
Authorization: `Bearer ${accessToken}`,
403+
},
404+
body: JSON.stringify(updateBody),
405+
})
406+
407+
if (!response.ok) {
408+
const errorData = await response.json().catch(() => null)
409+
logger.error('Confluence API error response:', {
410+
status: response.status,
411+
statusText: response.statusText,
412+
error: JSON.stringify(errorData, null, 2),
413+
})
414+
const errorMessage = errorData?.message || `Failed to update blog post (${response.status})`
415+
return NextResponse.json({ error: errorMessage }, { status: response.status })
416+
}
417+
418+
const data = await response.json()
419+
return NextResponse.json({
420+
id: data.id,
421+
title: data.title,
422+
status: data.status ?? null,
423+
spaceId: data.spaceId ?? null,
424+
authorId: data.authorId ?? null,
425+
createdAt: data.createdAt ?? null,
426+
version: data.version ?? null,
427+
body: data.body ?? null,
428+
webUrl: data._links?.webui ?? null,
429+
})
430+
} catch (error) {
431+
logger.error('Error updating blog post:', error)
432+
return NextResponse.json(
433+
{ error: (error as Error).message || 'Internal server error' },
434+
{ status: 500 }
435+
)
436+
}
437+
}
438+
439+
/**
440+
* Delete a blog post
441+
*/
442+
export async function DELETE(request: NextRequest) {
443+
try {
444+
const auth = await checkSessionOrInternalAuth(request)
445+
if (!auth.success || !auth.userId) {
446+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
447+
}
448+
449+
const body = await request.json()
450+
451+
const validation = deleteBlogPostSchema.safeParse(body)
452+
if (!validation.success) {
453+
const firstError = validation.error.errors[0]
454+
return NextResponse.json({ error: firstError.message }, { status: 400 })
455+
}
456+
457+
const { domain, accessToken, cloudId: providedCloudId, blogPostId } = validation.data
458+
459+
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
460+
461+
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
462+
if (!cloudIdValidation.isValid) {
463+
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
464+
}
465+
466+
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}`
467+
468+
const response = await fetch(url, {
469+
method: 'DELETE',
470+
headers: {
471+
Accept: 'application/json',
472+
Authorization: `Bearer ${accessToken}`,
473+
},
474+
})
475+
476+
if (!response.ok) {
477+
const errorData = await response.json().catch(() => null)
478+
logger.error('Confluence API error response:', {
479+
status: response.status,
480+
statusText: response.statusText,
481+
error: JSON.stringify(errorData, null, 2),
482+
})
483+
const errorMessage = errorData?.message || `Failed to delete blog post (${response.status})`
484+
return NextResponse.json({ error: errorMessage }, { status: response.status })
485+
}
486+
487+
return NextResponse.json({ blogPostId, deleted: true })
488+
} catch (error) {
489+
logger.error('Error deleting blog post:', error)
490+
return NextResponse.json(
491+
{ error: (error as Error).message || 'Internal server error' },
492+
{ status: 500 }
493+
)
494+
}
495+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { checkInternalAuth } from '@/lib/auth/hybrid'
4+
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
5+
import {
6+
downloadJsmAttachments,
7+
getJiraCloudId,
8+
getJsmApiBaseUrl,
9+
getJsmHeaders,
10+
} from '@/tools/jsm/utils'
11+
12+
export const dynamic = 'force-dynamic'
13+
14+
const logger = createLogger('JsmAttachmentsAPI')
15+
16+
export async function POST(request: NextRequest) {
17+
const auth = await checkInternalAuth(request)
18+
if (!auth.success || !auth.userId) {
19+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
20+
}
21+
22+
try {
23+
const body = await request.json()
24+
const {
25+
domain,
26+
accessToken,
27+
cloudId: cloudIdParam,
28+
issueIdOrKey,
29+
includeAttachments,
30+
start,
31+
limit,
32+
} = body
33+
34+
if (!domain) {
35+
logger.error('Missing domain in request')
36+
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
37+
}
38+
39+
if (!accessToken) {
40+
logger.error('Missing access token in request')
41+
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
42+
}
43+
44+
if (!issueIdOrKey) {
45+
logger.error('Missing issueIdOrKey in request')
46+
return NextResponse.json({ error: 'Issue ID or key is required' }, { status: 400 })
47+
}
48+
49+
const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
50+
if (!issueIdOrKeyValidation.isValid) {
51+
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 })
52+
}
53+
54+
const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))
55+
56+
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
57+
if (!cloudIdValidation.isValid) {
58+
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
59+
}
60+
61+
const baseUrl = getJsmApiBaseUrl(cloudId)
62+
const params = new URLSearchParams()
63+
if (start) params.append('start', start)
64+
if (limit) params.append('limit', limit)
65+
66+
const url = `${baseUrl}/request/${issueIdOrKey}/attachment${params.toString() ? `?${params.toString()}` : ''}`
67+
68+
logger.info('Fetching request attachments from:', url)
69+
70+
const response = await fetch(url, {
71+
method: 'GET',
72+
headers: getJsmHeaders(accessToken),
73+
})
74+
75+
if (!response.ok) {
76+
const errorText = await response.text()
77+
logger.error('JSM API error:', {
78+
status: response.status,
79+
statusText: response.statusText,
80+
error: errorText,
81+
})
82+
83+
return NextResponse.json(
84+
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
85+
{ status: response.status }
86+
)
87+
}
88+
89+
const data = await response.json()
90+
91+
const rawAttachments = data.values || []
92+
93+
const attachments = rawAttachments.map((att: Record<string, unknown>) => ({
94+
filename: att.filename ?? '',
95+
author: att.author
96+
? {
97+
accountId: (att.author as Record<string, unknown>).accountId ?? '',
98+
displayName: (att.author as Record<string, unknown>).displayName ?? '',
99+
active: (att.author as Record<string, unknown>).active ?? true,
100+
}
101+
: null,
102+
created: att.created ?? null,
103+
size: att.size ?? 0,
104+
mimeType: att.mimeType ?? '',
105+
}))
106+
107+
let files: Array<{ name: string; mimeType: string; data: string; size: number }> | undefined
108+
109+
if (includeAttachments && rawAttachments.length > 0) {
110+
const downloadable = rawAttachments
111+
.filter((att: Record<string, unknown>) => {
112+
const links = att._links as Record<string, string> | undefined
113+
return links?.content
114+
})
115+
.map((att: Record<string, unknown>) => ({
116+
contentUrl: (att._links as Record<string, string>).content as string,
117+
filename: (att.filename as string) ?? '',
118+
mimeType: (att.mimeType as string) ?? '',
119+
size: (att.size as number) ?? 0,
120+
}))
121+
122+
if (downloadable.length > 0) {
123+
files = await downloadJsmAttachments(downloadable, accessToken)
124+
}
125+
}
126+
127+
return NextResponse.json({
128+
success: true,
129+
output: {
130+
ts: new Date().toISOString(),
131+
issueIdOrKey,
132+
attachments,
133+
total: data.size || 0,
134+
isLastPage: data.isLastPage ?? true,
135+
...(files && files.length > 0 ? { files } : {}),
136+
},
137+
})
138+
} catch (error) {
139+
logger.error('Error fetching attachments:', {
140+
error: error instanceof Error ? error.message : String(error),
141+
stack: error instanceof Error ? error.stack : undefined,
142+
})
143+
144+
return NextResponse.json(
145+
{
146+
error: error instanceof Error ? error.message : 'Internal server error',
147+
success: false,
148+
},
149+
{ status: 500 }
150+
)
151+
}
152+
}

0 commit comments

Comments
 (0)