Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 157 additions & 48 deletions apps/docs/content/docs/en/tools/confluence.mdx

Large diffs are not rendered by default.

525 changes: 506 additions & 19 deletions apps/docs/content/docs/en/tools/jira_service_management.mdx

Large diffs are not rendered by default.

210 changes: 210 additions & 0 deletions apps/sim/app/api/tools/confluence/blogposts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,45 @@ const createBlogPostSchema = z.object({
status: z.enum(['current', 'draft']).optional(),
})

const updateBlogPostSchema = z
.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
blogPostId: z.string().min(1, 'Blog post ID is required'),
title: z.string().optional(),
content: z.string().optional(),
status: z.enum(['current', 'draft']).optional(),
})
.refine(
(data) => {
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
return validation.isValid
},
(data) => {
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
return { message: validation.error || 'Invalid blog post ID', path: ['blogPostId'] }
}
)

const deleteBlogPostSchema = z
.object({
domain: z.string().min(1, 'Domain is required'),
accessToken: z.string().min(1, 'Access token is required'),
cloudId: z.string().optional(),
blogPostId: z.string().min(1, 'Blog post ID is required'),
})
.refine(
(data) => {
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
return validation.isValid
},
(data) => {
const validation = validateAlphanumericId(data.blogPostId, 'blogPostId', 255)
return { message: validation.error || 'Invalid blog post ID', path: ['blogPostId'] }
}
)

/**
* List all blog posts or get a specific blog post
*/
Expand Down Expand Up @@ -283,3 +322,174 @@ export async function POST(request: NextRequest) {
)
}
}

/**
* Update a blog post
*/
export async function PUT(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}

const body = await request.json()

const validation = updateBlogPostSchema.safeParse(body)
if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}

const {
domain,
accessToken,
cloudId: providedCloudId,
blogPostId,
title,
content,
status,
} = validation.data

const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))

const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}

const blogPostUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}`

const currentResponse = await fetch(blogPostUrl, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})

if (!currentResponse.ok) {
const errorData = await currentResponse.json().catch(() => null)
const errorMessage =
errorData?.message || `Failed to fetch blog post for update (${currentResponse.status})`
return NextResponse.json({ error: errorMessage }, { status: currentResponse.status })
}

const currentPost = await currentResponse.json()
const currentVersion = currentPost.version.number

const updateBody: Record<string, unknown> = {
id: blogPostId,
version: {
number: currentVersion + 1,
message: 'Updated via Sim',
},
status: status || currentPost.status || 'current',
title: title || currentPost.title,
}

if (content) {
updateBody.body = {
representation: 'storage',
value: content,
}
}

const response = await fetch(blogPostUrl, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(updateBody),
})

if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to update blog post (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}

const data = await response.json()
return NextResponse.json({
id: data.id,
title: data.title,
status: data.status ?? null,
spaceId: data.spaceId ?? null,
authorId: data.authorId ?? null,
createdAt: data.createdAt ?? null,
version: data.version ?? null,
body: data.body ?? null,
webUrl: data._links?.webui ?? null,
})
} catch (error) {
logger.error('Error updating blog post:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

/**
* Delete a blog post
*/
export async function DELETE(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}

const body = await request.json()

const validation = deleteBlogPostSchema.safeParse(body)
if (!validation.success) {
const firstError = validation.error.errors[0]
return NextResponse.json({ error: firstError.message }, { status: 400 })
}

const { domain, accessToken, cloudId: providedCloudId, blogPostId } = validation.data

const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))

const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}

const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}`

const response = await fetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})

if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: JSON.stringify(errorData, null, 2),
})
const errorMessage = errorData?.message || `Failed to delete blog post (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}

return NextResponse.json({ blogPostId, deleted: true })
} catch (error) {
logger.error('Error deleting blog post:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}
152 changes: 152 additions & 0 deletions apps/sim/app/api/tools/jsm/attachments/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import {
downloadJsmAttachments,
getJiraCloudId,
getJsmApiBaseUrl,
getJsmHeaders,
} from '@/tools/jsm/utils'

export const dynamic = 'force-dynamic'

const logger = createLogger('JsmAttachmentsAPI')

export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}

try {
const body = await request.json()
const {
domain,
accessToken,
cloudId: cloudIdParam,
issueIdOrKey,
includeAttachments,
start,
limit,
} = body

if (!domain) {
logger.error('Missing domain in request')
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}

if (!accessToken) {
logger.error('Missing access token in request')
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}

if (!issueIdOrKey) {
logger.error('Missing issueIdOrKey in request')
return NextResponse.json({ error: 'Issue ID or key is required' }, { status: 400 })
}

const issueIdOrKeyValidation = validateJiraIssueKey(issueIdOrKey, 'issueIdOrKey')
if (!issueIdOrKeyValidation.isValid) {
return NextResponse.json({ error: issueIdOrKeyValidation.error }, { status: 400 })
}

const cloudId = cloudIdParam || (await getJiraCloudId(domain, accessToken))

const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}

const baseUrl = getJsmApiBaseUrl(cloudId)
const params = new URLSearchParams()
if (start) params.append('start', start)
if (limit) params.append('limit', limit)

const url = `${baseUrl}/request/${issueIdOrKey}/attachment${params.toString() ? `?${params.toString()}` : ''}`

logger.info('Fetching request attachments from:', url)

const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})

if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})

return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}

const data = await response.json()

const rawAttachments = data.values || []

const attachments = rawAttachments.map((att: Record<string, unknown>) => ({
filename: att.filename ?? '',
author: att.author
? {
accountId: (att.author as Record<string, unknown>).accountId ?? '',
displayName: (att.author as Record<string, unknown>).displayName ?? '',
active: (att.author as Record<string, unknown>).active ?? true,
}
: null,
created: att.created ?? null,
size: att.size ?? 0,
mimeType: att.mimeType ?? '',
}))

let files: Array<{ name: string; mimeType: string; data: string; size: number }> | undefined

if (includeAttachments && rawAttachments.length > 0) {
const downloadable = rawAttachments
.filter((att: Record<string, unknown>) => {
const links = att._links as Record<string, string> | undefined
return links?.content
})
.map((att: Record<string, unknown>) => ({
contentUrl: (att._links as Record<string, string>).content as string,
filename: (att.filename as string) ?? '',
mimeType: (att.mimeType as string) ?? '',
size: (att.size as number) ?? 0,
}))

if (downloadable.length > 0) {
files = await downloadJsmAttachments(downloadable, accessToken)
}
}

return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueIdOrKey,
attachments,
total: data.size || 0,
isLastPage: data.isLastPage ?? true,
...(files && files.length > 0 ? { files } : {}),
},
})
} catch (error) {
logger.error('Error fetching attachments:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})

return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Internal server error',
success: false,
},
{ status: 500 }
)
}
}
Loading