chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,83 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceDeleteAttachmentContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceAttachmentAPI')
export const dynamic = 'force-dynamic'
// Delete an attachment
export const DELETE = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceDeleteAttachmentContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, cloudId: providedCloudId, attachmentId } = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!attachmentId) {
return NextResponse.json({ error: 'Attachment ID is required' }, { status: 400 })
}
const attachmentIdValidation = validateAlphanumericId(attachmentId, 'attachmentId', 255)
if (!attachmentIdValidation.isValid) {
return NextResponse.json({ error: attachmentIdValidation.error }, { status: 400 })
}
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/attachments/${attachmentId}`
const response = await fetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
return NextResponse.json({ attachmentId, deleted: true })
} catch (error) {
logger.error('Error deleting Confluence attachment:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,116 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceListAttachmentsContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceAttachmentsAPI')
export const dynamic = 'force-dynamic'
// List attachments on a page
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceListAttachmentsContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
pageId,
cloudId: providedCloudId,
limit,
cursor,
} = parsed.data.query
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/attachments?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const attachments = (data.results || []).map((attachment: any) => ({
id: attachment.id,
title: attachment.title,
fileSize: attachment.fileSize || 0,
mediaType: attachment.mediaType || '',
downloadUrl: attachment.downloadLink || attachment._links?.download || '',
status: attachment.status ?? null,
webuiUrl: attachment._links?.webui ?? null,
pageId: attachment.pageId ?? null,
blogPostId: attachment.blogPostId ?? null,
comment: attachment.comment ?? null,
version: attachment.version ?? null,
}))
return NextResponse.json({
attachments,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing Confluence attachments:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,413 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
confluenceBlogPostOperationContract,
confluenceDeleteBlogPostContract,
confluenceListBlogPostsContract,
confluenceUpdateBlogPostContract,
} from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceBlogPostsAPI')
export const dynamic = 'force-dynamic'
/**
* List all blog posts or get a specific blog post
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceListBlogPostsContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
cloudId: providedCloudId,
limit,
status,
sort: sortOrder,
cursor,
} = parsed.data.query
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
if (status) {
queryParams.append('status', status)
}
if (sortOrder) {
queryParams.append('sort', sortOrder)
}
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const blogPosts = (data.results || []).map((post: any) => ({
id: post.id,
title: post.title,
status: post.status ?? null,
spaceId: post.spaceId ?? null,
authorId: post.authorId ?? null,
createdAt: post.createdAt ?? null,
version: post.version ?? null,
webUrl: post._links?.webui ?? null,
}))
return NextResponse.json({
blogPosts,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing blog posts:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
/**
* Get a specific blog post by ID
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceBlogPostOperationContract, request, {})
if (!parsed.success) return parsed.response
const body = parsed.data.body
if ('title' in body && 'content' in body && 'spaceId' in body) {
// Create blog post
const {
domain,
accessToken,
cloudId: providedCloudId,
spaceId,
title,
content,
status,
} = body
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`
const createBody = {
spaceId,
status: status || 'current',
title,
body: {
representation: 'storage',
value: content,
},
}
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(createBody),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
id: data.id,
title: data.title,
spaceId: data.spaceId,
webUrl: data._links?.webui ?? null,
})
}
// Get blog post by ID
const { domain, accessToken, cloudId: providedCloudId, blogPostId, bodyFormat } = body
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
if (bodyFormat) {
queryParams.append('body-format', bodyFormat)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ 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 with blog post operation:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
/**
* Update a blog post
*/
export const PUT = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceUpdateBlogPostContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
blogPostId,
title,
content,
cloudId: providedCloudId,
} = parsed.data.body
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
// Fetch current blog post to get version number
const currentUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/blogposts/${blogPostId}?body-format=storage`
const currentResponse = await fetch(currentUrl, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!currentResponse.ok) {
const errorText = await currentResponse.text()
throw new Error(
parseAtlassianErrorMessage(currentResponse.status, currentResponse.statusText, errorText)
)
}
const currentPost = await currentResponse.json()
if (!currentPost.version?.number) {
return NextResponse.json(
{ error: 'Unable to determine current blog post version' },
{ status: 422 }
)
}
const currentVersion = currentPost.version.number
const updateBody: Record<string, unknown> = {
id: blogPostId,
version: { number: currentVersion + 1 },
status: 'current',
title: title || currentPost.title,
body: {
representation: 'storage',
value: content || currentPost.body?.storage?.value || '',
},
}
const response = await fetch(currentUrl, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(updateBody),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} 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 const DELETE = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceDeleteBlogPostContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, blogPostId, cloudId: providedCloudId } = parsed.data.body
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 errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ 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 }
)
}
})
@@ -0,0 +1,205 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
confluenceDeleteCommentContract,
confluenceUpdateCommentContract,
} from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceCommentAPI')
export const dynamic = 'force-dynamic'
// Update a comment
export const PUT = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceUpdateCommentContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, cloudId: providedCloudId, commentId, comment } = parsed.data.body
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
// Detect comment type — try footer-comments first, fall back to inline-comments
const apiBase = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2`
let commentEndpoint = 'footer-comments'
let getResponse = await fetch(`${apiBase}/footer-comments/${commentId}`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (getResponse.status === 404) {
commentEndpoint = 'inline-comments'
getResponse = await fetch(`${apiBase}/inline-comments/${commentId}`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
}
if (!getResponse.ok) {
const errorText = await getResponse.text()
throw new Error(
parseAtlassianErrorMessage(getResponse.status, getResponse.statusText, errorText)
)
}
const currentComment = await getResponse.json()
const currentVersion = currentComment.version?.number || 1
const url = `${apiBase}/${commentEndpoint}/${commentId}`
const updateBody = {
body: {
representation: 'storage',
value: comment,
},
version: {
number: currentVersion + 1,
message: 'Updated via Sim',
},
}
const response = await fetch(url, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(updateBody),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error updating Confluence comment:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
// Delete a comment
export const DELETE = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceDeleteCommentContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, cloudId: providedCloudId, commentId } = parsed.data.body
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const apiBase = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2`
// Detect comment type with a non-destructive GET so a 404 from a prior
// deletion isn't masked by a second DELETE attempt against the wrong endpoint.
let commentEndpoint = 'footer-comments'
let detectResponse = await fetch(`${apiBase}/footer-comments/${commentId}`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (detectResponse.status === 404) {
commentEndpoint = 'inline-comments'
detectResponse = await fetch(`${apiBase}/inline-comments/${commentId}`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
}
if (!detectResponse.ok) {
const errorText = await detectResponse.text()
logger.error('Confluence API error response:', {
status: detectResponse.status,
statusText: detectResponse.statusText,
error: errorText,
})
return NextResponse.json(
{
error: parseAtlassianErrorMessage(
detectResponse.status,
detectResponse.statusText,
errorText
),
},
{ status: detectResponse.status }
)
}
const response = await fetch(`${apiBase}/${commentEndpoint}/${commentId}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
return NextResponse.json({ commentId, deleted: true })
} catch (error) {
logger.error('Error deleting Confluence comment:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,213 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
confluenceCreateCommentContract,
confluenceListCommentsContract,
} from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceCommentsAPI')
export const dynamic = 'force-dynamic'
// Create a comment
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceCreateCommentContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, cloudId: providedCloudId, pageId, comment } = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
if (!comment) {
return NextResponse.json({ error: 'Comment is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
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/footer-comments`
logger.info('Calling Confluence API', { url })
const body = {
pageId,
body: {
representation: 'storage',
value: comment,
},
}
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({ ...data, pageId })
} catch (error) {
logger.error('Error creating Confluence comment:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
// List comments on a page
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceListCommentsContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
pageId,
cloudId: providedCloudId,
limit,
bodyFormat,
cursor,
} = parsed.data.query
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
queryParams.append('body-format', bodyFormat)
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/footer-comments?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const comments = (data.results || []).map((comment: any) => {
const bodyValue = comment.body?.storage?.value || comment.body?.view?.value || ''
return {
id: comment.id,
body: {
value: bodyValue,
representation: bodyFormat,
},
createdAt: comment.createdAt || '',
authorId: comment.authorId || '',
status: comment.status ?? null,
title: comment.title ?? null,
pageId: comment.pageId ?? null,
blogPostId: comment.blogPostId ?? null,
parentCommentId: comment.parentCommentId ?? null,
version: comment.version ?? null,
}
})
return NextResponse.json({
comments,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing Confluence comments:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,133 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceCreatePageContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceCreatePageAPI')
export const dynamic = 'force-dynamic'
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceCreatePageContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
cloudId: providedCloudId,
spaceId,
title,
content,
parentId,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
if (!/^\d+$/.test(String(spaceId))) {
return NextResponse.json(
{
error:
'Invalid Space ID. The Space ID must be a numeric value, not the space key from the URL. Use the "list" operation to get all spaces with their numeric IDs.',
},
{ status: 400 }
)
}
if (!title) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 })
}
if (!content) {
return NextResponse.json({ error: 'Content is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
if (parentId) {
const parentIdValidation = validateAlphanumericId(parentId, 'parentId', 255)
if (!parentIdValidation.isValid) {
return NextResponse.json({ error: parentIdValidation.error }, { status: 400 })
}
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const createBody: any = {
spaceId,
status: 'current',
title,
body: {
representation: 'storage',
value: content,
},
}
if (parentId !== undefined && parentId !== null && parentId !== '') {
createBody.parentId = parentId
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages`
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(createBody),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
let errorMessage = parseAtlassianErrorMessage(response.status, response.statusText, errorText)
if (errorMessage.includes("'spaceId'") && errorMessage.includes('Long')) {
errorMessage = 'Invalid Space ID. Use the list spaces operation to find valid space IDs.'
}
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error creating Confluence page:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,289 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
confluenceDeleteLabelContract,
confluenceLabelMutationContract,
confluenceListLabelsContract,
} from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceLabelsAPI')
export const dynamic = 'force-dynamic'
// Add a label to a page
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceLabelMutationContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
cloudId: providedCloudId,
pageId,
labelName,
prefix: labelPrefix,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
if (!labelName) {
return NextResponse.json({ error: 'Label name is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
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/rest/api/content/${pageId}/label`
const body = [
{
prefix: labelPrefix || 'global',
name: labelName,
},
]
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const addedLabel = data.results?.[0] || data[0] || data
return NextResponse.json({
id: addedLabel.id ?? '',
name: addedLabel.name ?? labelName,
prefix: addedLabel.prefix ?? labelPrefix ?? 'global',
pageId,
labelName,
})
} catch (error) {
logger.error('Error adding Confluence label:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
// List labels on a page
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceListLabelsContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
pageId,
cloudId: providedCloudId,
limit,
cursor,
} = parsed.data.query
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/labels?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const labels = (data.results || []).map((label: any) => ({
id: label.id,
name: label.name,
prefix: label.prefix || 'global',
}))
return NextResponse.json({
labels,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing Confluence labels:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
// Delete a label from a page
export const DELETE = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceDeleteLabelContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, cloudId: providedCloudId, pageId, labelName } = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
if (!labelName) {
return NextResponse.json({ error: 'Label name is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const encodedLabel = encodeURIComponent(labelName.trim())
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/content/${pageId}/label?name=${encodedLabel}`
const response = await fetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
return NextResponse.json({
pageId,
labelName,
deleted: true,
})
} catch (error) {
logger.error('Error deleting Confluence label:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,104 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluencePageAncestorsContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluencePageAncestorsAPI')
export const dynamic = 'force-dynamic'
/**
* Get ancestors (parent pages) of a specific Confluence page.
* Uses GET /wiki/api/v2/pages/{id}/ancestors
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluencePageAncestorsContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, pageId, cloudId: providedCloudId, limit } = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/ancestors?${queryParams.toString()}`
logger.info(`Fetching ancestors for page ${pageId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const ancestors = (data.results || []).map((page: any) => ({
id: page.id,
title: page.title,
status: page.status ?? null,
spaceId: page.spaceId ?? null,
webUrl: page._links?.webui ?? null,
}))
return NextResponse.json({
ancestors,
pageId,
})
} catch (error) {
logger.error('Error getting page ancestors:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,119 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluencePageChildrenContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluencePageChildrenAPI')
export const dynamic = 'force-dynamic'
/**
* Get child pages of a specific Confluence page.
* Uses GET /wiki/api/v2/pages/{id}/children
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluencePageChildrenContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
pageId,
cloudId: providedCloudId,
limit,
cursor,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/children?${queryParams.toString()}`
logger.info(`Fetching child pages for page ${pageId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const children = (data.results || []).map((page: any) => ({
id: page.id,
title: page.title,
status: page.status ?? null,
spaceId: page.spaceId ?? null,
childPosition: page.childPosition ?? null,
webUrl: page._links?.webui ?? null,
}))
return NextResponse.json({
children,
parentId: pageId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error getting child pages:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,129 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluencePageDescendantsContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateJiraCloudId,
validatePaginationCursor,
} from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluencePageDescendantsAPI')
export const dynamic = 'force-dynamic'
/**
* Get all descendants of a Confluence page recursively.
* Uses GET /wiki/api/v2/pages/{id}/descendants
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluencePageDescendantsContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
pageId,
cloudId: providedCloudId,
limit,
cursor,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/descendants?${queryParams.toString()}`
logger.info(`Fetching descendants for page ${pageId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const descendants = (data.results || []).map((page: any) => ({
id: page.id,
title: page.title,
type: page.type ?? null,
status: page.status ?? null,
spaceId: page.spaceId ?? null,
parentId: page.parentId ?? null,
childPosition: page.childPosition ?? null,
depth: page.depth ?? null,
}))
return NextResponse.json({
descendants,
pageId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error getting page descendants:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,371 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
confluenceCreatePagePropertyContract,
confluenceDeletePagePropertyContract,
confluenceListPagePropertiesContract,
confluenceUpdatePagePropertyContract,
} from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluencePagePropertiesAPI')
export const dynamic = 'force-dynamic'
/**
* List all content properties on a page.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceListPagePropertiesContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
pageId,
cloudId: providedCloudId,
limit,
cursor,
} = parsed.data.query
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/properties?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const properties = (data.results || []).map((prop: any) => ({
id: prop.id,
key: prop.key,
value: prop.value ?? null,
version: prop.version ?? null,
}))
return NextResponse.json({
properties,
pageId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing page properties:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
/**
* Create a new content property on a page.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceCreatePagePropertyContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, cloudId: providedCloudId, pageId, key, value } = parsed.data.body
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
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/pages/${pageId}/properties`
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ key, value }),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
id: data.id,
key: data.key,
value: data.value,
version: data.version,
pageId,
})
} catch (error) {
logger.error('Error creating page property:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
/**
* Update a content property on a page.
*/
export const PUT = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceUpdatePagePropertyContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
cloudId: providedCloudId,
pageId,
propertyId,
key,
value,
versionNumber,
} = parsed.data.body
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const propertyIdValidation = validateAlphanumericId(propertyId, 'propertyId', 255)
if (!propertyIdValidation.isValid) {
return NextResponse.json({ error: propertyIdValidation.error }, { status: 400 })
}
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/pages/${pageId}/properties/${propertyId}`
let nextVersion = versionNumber
if (nextVersion === undefined) {
const lookupResponse = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!lookupResponse.ok) {
const errorText = await lookupResponse.text()
return NextResponse.json(
{
error: parseAtlassianErrorMessage(
lookupResponse.status,
lookupResponse.statusText,
errorText
),
},
{ status: lookupResponse.status }
)
}
const current = await lookupResponse.json()
const currentNumber = current?.version?.number
if (typeof currentNumber !== 'number') {
return NextResponse.json(
{ error: 'Could not determine current property version' },
{ status: 500 }
)
}
nextVersion = currentNumber + 1
}
const response = await fetch(url, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
key,
value,
version: { number: nextVersion },
}),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
id: data.id,
key: data.key,
value: data.value,
version: data.version,
pageId,
})
} catch (error) {
logger.error('Error updating page property:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
/**
* Delete a content property from a page.
*/
export const DELETE = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceDeletePagePropertyContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, cloudId: providedCloudId, pageId, propertyId } = parsed.data.body
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const propertyIdValidation = validateAlphanumericId(propertyId, 'propertyId', 255)
if (!propertyIdValidation.isValid) {
return NextResponse.json({ error: propertyIdValidation.error }, { status: 400 })
}
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/pages/${pageId}/properties/${propertyId}`
const response = await fetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
return NextResponse.json({ propertyId, pageId, deleted: true })
} catch (error) {
logger.error('Error deleting page property:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,218 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluencePageVersionsContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateJiraCloudId,
validateNumericId,
validatePaginationCursor,
} from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { cleanHtmlContent, getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluencePageVersionsAPI')
export const dynamic = 'force-dynamic'
/**
* List all versions of a page or get a specific version.
* Uses GET /wiki/api/v2/pages/{id}/versions
* and GET /wiki/api/v2/pages/{page-id}/versions/{version-number}
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluencePageVersionsContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
pageId,
versionNumber,
cloudId: providedCloudId,
limit = 50,
cursor,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
// If versionNumber is provided, get specific version with page content
if (versionNumber !== undefined && versionNumber !== null) {
const versionValidation = validateNumericId(versionNumber, 'versionNumber', { min: 1 })
if (!versionValidation.isValid) {
return NextResponse.json({ error: versionValidation.error }, { status: 400 })
}
const safeVersion = versionValidation.sanitized
const versionUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions/${safeVersion}`
const pageUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?version=${safeVersion}&body-format=storage`
logger.info(`Fetching version ${versionNumber} for page ${pageId}`)
const [versionResponse, pageResponse] = await Promise.all([
fetch(versionUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}),
fetch(pageUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
}),
])
if (!versionResponse.ok) {
const errorText = await versionResponse.text()
logger.error('Confluence API error response:', {
status: versionResponse.status,
statusText: versionResponse.statusText,
error: errorText,
})
return NextResponse.json(
{
error: parseAtlassianErrorMessage(
versionResponse.status,
versionResponse.statusText,
errorText
),
},
{ status: versionResponse.status }
)
}
const versionData = await versionResponse.json()
let title: string | null = null
let content: string | null = null
let body: Record<string, unknown> | null = null
if (pageResponse.ok) {
const pageData = await pageResponse.json()
title = pageData.title ?? null
body = pageData.body ?? null
const rawContent =
pageData.body?.storage?.value ||
pageData.body?.view?.value ||
pageData.body?.atlas_doc_format?.value ||
''
if (rawContent) {
content = cleanHtmlContent(rawContent)
}
} else {
logger.warn(
`Could not fetch page content for version ${versionNumber}: ${pageResponse.status}`
)
}
return NextResponse.json({
version: {
number: versionData.number,
message: versionData.message ?? null,
minorEdit: versionData.minorEdit ?? false,
authorId: versionData.authorId ?? null,
createdAt: versionData.createdAt ?? null,
},
pageId,
title,
content,
body,
})
}
// List all versions
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}/versions?${queryParams.toString()}`
logger.info(`Fetching versions for page ${pageId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const versions = (data.results || []).map((version: any) => ({
number: version.number,
message: version.message ?? null,
minorEdit: version.minorEdit ?? false,
authorId: version.authorId ?? null,
createdAt: version.createdAt ?? null,
}))
return NextResponse.json({
versions,
pageId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error with page versions:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,254 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
confluenceDeletePageContract,
confluencePageSelectorContract,
confluenceUpdatePageContract,
} from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluencePageAPI')
export const dynamic = 'force-dynamic'
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluencePageSelectorContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, cloudId: providedCloudId, pageId } = parsed.data.body
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/pages/${pageId}?body-format=storage`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
id: data.id,
title: data.title,
body: {
storage: {
value: data.body?.storage?.value ?? null,
representation: 'storage',
},
},
status: data.status ?? null,
spaceId: data.spaceId ?? null,
parentId: data.parentId ?? null,
authorId: data.authorId ?? null,
createdAt: data.createdAt ?? null,
version: data.version ?? null,
_links: data._links ?? null,
})
} catch (error) {
logger.error('Error fetching Confluence page:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
export const PUT = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceUpdatePageContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
pageId,
cloudId: providedCloudId,
title,
body: pageBody,
version,
} = parsed.data.body
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const currentPageUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?body-format=storage`
const currentPageResponse = await fetch(currentPageUrl, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!currentPageResponse.ok) {
const errorText = await currentPageResponse.text()
throw new Error(
parseAtlassianErrorMessage(
currentPageResponse.status,
currentPageResponse.statusText,
errorText
)
)
}
const currentPage = await currentPageResponse.json()
const currentVersion = currentPage.version.number
const updateBody: any = {
id: pageId,
version: {
number: currentVersion + 1,
message: version?.message || 'Updated via API',
},
status: 'current',
}
if (title !== undefined && title !== null && title !== '') {
updateBody.title = title
} else {
updateBody.title = currentPage.title
}
if (pageBody?.value !== undefined && pageBody?.value !== null && pageBody?.value !== '') {
updateBody.body = {
representation: 'storage',
value: pageBody.value,
}
} else {
updateBody.body = {
representation: 'storage',
value: currentPage.body?.storage?.value || '',
}
}
const response = await fetch(currentPageUrl, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(updateBody),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error updating Confluence page:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
export const DELETE = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceDeletePageContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, cloudId: providedCloudId, pageId, purge } = parsed.data.body
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
if (purge) {
queryParams.append('purge', 'true')
}
const queryString = queryParams.toString()
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
return NextResponse.json({ pageId, deleted: true })
} catch (error) {
logger.error('Error deleting Confluence page:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,113 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluencePagesByLabelContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluencePagesByLabelAPI')
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluencePagesByLabelContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
labelId,
cloudId: providedCloudId,
limit,
cursor,
} = parsed.data.query
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!labelId) {
return NextResponse.json({ error: 'Label ID is required' }, { status: 400 })
}
const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255)
if (!labelIdValidation.isValid) {
return NextResponse.json({ error: labelIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/labels/${labelId}/pages?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const pages = (data.results || []).map((page: any) => ({
id: page.id,
title: page.title,
status: page.status ?? null,
spaceId: page.spaceId ?? null,
parentId: page.parentId ?? null,
authorId: page.authorId ?? null,
createdAt: page.createdAt ?? null,
version: page.version ?? null,
}))
return NextResponse.json({
pages,
labelId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error getting pages by label:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,103 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluencePagesSelectorContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluencePagesAPI')
export const dynamic = 'force-dynamic'
// List pages or search pages
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluencePagesSelectorContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, title, cloudId: providedCloudId, limit } = parsed.data.body
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages`
const queryParams = new URLSearchParams()
if (limit) {
queryParams.append('limit', limit.toString())
}
if (title) {
queryParams.append('title', title)
}
const queryString = queryParams.toString()
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl
logger.info(`Fetching Confluence pages from: ${url}`)
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
logger.info('Response status:', response.status, response.statusText)
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
logger.info('Confluence API response:', `${JSON.stringify(data, null, 2).substring(0, 300)}...`)
logger.info(`Found ${data.results?.length || 0} pages`)
if (data.results && data.results.length > 0) {
logger.info('First few pages:')
for (const page of data.results.slice(0, 3)) {
logger.info(`- ${page.id}: ${page.title}`)
}
}
return NextResponse.json({
files: data.results.map((page: any) => ({
id: page.id,
name: page.title,
mimeType: 'confluence/page',
url: page._links?.webui || '',
modifiedTime: page.version?.createdAt || '',
spaceId: page.spaceId,
webViewLink: page._links?.webui || '',
})),
})
} catch (error) {
logger.error('Error fetching Confluence pages:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,128 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceSearchInSpaceContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceSearchInSpaceAPI')
export const dynamic = 'force-dynamic'
/**
* Search for content within a specific Confluence space using CQL.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceSearchInSpaceContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
spaceKey,
query,
cloudId: providedCloudId,
limit = 25,
contentType,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceKey) {
return NextResponse.json({ error: 'Space key is required' }, { status: 400 })
}
const spaceKeyValidation = validateAlphanumericId(spaceKey, 'spaceKey', 255)
if (!spaceKeyValidation.isValid) {
return NextResponse.json({ error: spaceKeyValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const escapeCqlValue = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
let cql = `space = "${escapeCqlValue(spaceKey)}"`
if (query) {
cql += ` AND text ~ "${escapeCqlValue(query)}"`
}
if (contentType) {
cql += ` AND type = "${escapeCqlValue(contentType)}"`
}
const searchParams = new URLSearchParams({
cql,
limit: String(Math.min(limit, 250)),
})
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/search?${searchParams.toString()}`
logger.info(`Searching in space ${spaceKey} with CQL: ${cql}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const results = (data.results || []).map((result: any) => ({
id: result.content?.id ?? result.id,
title: result.content?.title ?? result.title,
type: result.content?.type ?? result.type,
status: result.content?.status ?? null,
url: result.url ?? result._links?.webui ?? '',
excerpt: result.excerpt ?? '',
lastModified: result.lastModified ?? null,
}))
return NextResponse.json({
results,
spaceKey,
totalSize: data.totalSize ?? results.length,
})
} catch (error) {
logger.error('Error searching in space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,108 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceSearchContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('Confluence Search')
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceSearchContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, cloudId: providedCloudId, query, limit } = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!query) {
return NextResponse.json({ error: 'Search query is required' }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const escapeCqlValue = (value: string) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
const searchParams = new URLSearchParams({
cql: `text ~ "${escapeCqlValue(query)}"`,
limit: limit.toString(),
})
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/search?${searchParams.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const results = (data.results || []).map((result: any) => {
const spaceData = result.resultGlobalContainer || result.content?.space
return {
id: result.content?.id || result.id,
title: result.content?.title || result.title,
type: result.content?.type || result.type,
url: result.url || result._links?.webui || '',
excerpt: result.excerpt || '',
status: result.content?.status ?? null,
spaceKey: result.resultGlobalContainer?.key ?? result.content?.space?.key ?? null,
space: spaceData
? {
id: spaceData.id ?? null,
key: spaceData.key ?? null,
name: spaceData.name ?? spaceData.title ?? null,
}
: null,
lastModified: result.lastModified ?? result.content?.history?.lastUpdated?.when ?? null,
entityType: result.entityType ?? null,
}
})
return NextResponse.json({ results })
} catch (error) {
logger.error('Error searching Confluence:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,153 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceSpacesSelectorContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
import {
getAtlassianServiceAccountSecret,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceSelectorSpacesAPI')
export const dynamic = 'force-dynamic'
const PAGE_LIMIT = 250
type SpaceStatus = 'current' | 'archived'
/**
* Cursor format: `<status>:<innerCursor>`. Empty inner cursor means "first page
* of that status". When current is exhausted we hand back `archived:` so the
* client transparently flips to the archived stream — listing both surfaces
* archived spaces in the dropdown, which would otherwise only be reachable by
* typing the space key manually even though sync works against archived spaces.
*/
function parseCursor(raw: string | undefined): { status: SpaceStatus; inner?: string } {
if (!raw) return { status: 'current' }
const idx = raw.indexOf(':')
if (idx === -1) return { status: 'current' }
const status = raw.slice(0, idx) === 'archived' ? 'archived' : 'current'
const inner = raw.slice(idx + 1)
return { status, inner: inner || undefined }
}
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const parsed = await parseRequest(confluenceSpacesSelectorContract, request, {})
if (!parsed.success) return parsed.response
const { credential, workflowId, domain, cursor } = parsed.data.body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { 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 resolved = await resolveOAuthAccountId(credential)
const isAtlassianServiceAccount =
resolved?.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID && !!resolved.credentialId
let accessToken: string | null
let cloudId: string
if (isAtlassianServiceAccount) {
const secret = await getAtlassianServiceAccountSecret(resolved.credentialId!)
accessToken = secret.apiToken
cloudId = secret.cloudId
} else {
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 }
)
}
cloudId = await getConfluenceCloudId(domain, accessToken)
}
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces`
const { status, inner } = parseCursor(cursor)
const params = new URLSearchParams({ limit: String(PAGE_LIMIT), status })
if (inner) params.set('cursor', inner)
const url = `${baseUrl}?${params.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
})
if (!response.ok) {
const errorText = await response.text()
const message = parseAtlassianErrorMessage(response.status, response.statusText, errorText)
logger.error('Confluence API error response', { error: message, status: response.status })
return NextResponse.json({ error: message }, { status: 502 })
}
const data = await response.json()
const spaces = (data.results || []).map((space: { id: string; name: string; key: string }) => ({
id: space.id,
name: space.name,
key: space.key,
status,
}))
let nextInner: string | undefined
const nextLink = data._links?.next as string | undefined
if (nextLink) {
try {
nextInner = new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined
} catch {
nextInner = undefined
}
}
let nextCursor: string | undefined
if (nextInner) {
nextCursor = `${status}:${nextInner}`
} else if (status === 'current') {
nextCursor = 'archived:'
}
return NextResponse.json({ spaces, nextCursor })
} catch (error) {
logger.error('Error listing Confluence spaces:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,131 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceSpaceBlogPostsContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceSpaceBlogPostsAPI')
export const dynamic = 'force-dynamic'
/**
* List all blog posts in a specific Confluence space.
* Uses GET /wiki/api/v2/spaces/{id}/blogposts
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceSpaceBlogPostsContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
spaceId,
cloudId: providedCloudId,
limit = 25,
status,
bodyFormat,
cursor,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (status) {
queryParams.append('status', status)
}
if (bodyFormat) {
queryParams.append('body-format', bodyFormat)
}
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/blogposts?${queryParams.toString()}`
logger.info(`Fetching blog posts in space ${spaceId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const blogPosts = (data.results || []).map((post: any) => ({
id: post.id,
title: post.title,
status: post.status ?? null,
spaceId: post.spaceId ?? null,
authorId: post.authorId ?? null,
createdAt: post.createdAt ?? null,
version: post.version ?? null,
body: post.body ?? null,
webUrl: post._links?.webui ?? null,
}))
return NextResponse.json({
blogPosts,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing blog posts in space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,108 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceSpaceLabelsContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceSpaceLabelsAPI')
export const dynamic = 'force-dynamic'
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceSpaceLabelsContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
spaceId,
cloudId: providedCloudId,
limit,
cursor,
} = parsed.data.query
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/labels?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const labels = (data.results || []).map((label: any) => ({
id: label.id,
name: label.name,
prefix: label.prefix || 'global',
}))
return NextResponse.json({
labels,
spaceId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing space labels:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,132 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceSpacePagesContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceSpacePagesAPI')
export const dynamic = 'force-dynamic'
/**
* List all pages in a specific Confluence space.
* Uses GET /wiki/api/v2/spaces/{id}/pages
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceSpacePagesContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
spaceId,
cloudId: providedCloudId,
limit = 50,
status,
bodyFormat,
cursor,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (status) {
queryParams.append('status', status)
}
if (bodyFormat) {
queryParams.append('body-format', bodyFormat)
}
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/pages?${queryParams.toString()}`
logger.info(`Fetching pages in space ${spaceId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const pages = (data.results || []).map((page: any) => ({
id: page.id,
title: page.title,
status: page.status ?? null,
spaceId: page.spaceId ?? null,
parentId: page.parentId ?? null,
authorId: page.authorId ?? null,
createdAt: page.createdAt ?? null,
version: page.version ?? null,
body: page.body ?? null,
webUrl: page._links?.webui ?? null,
}))
return NextResponse.json({
pages,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing pages in space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,128 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceSpacePermissionsContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateJiraCloudId,
validatePaginationCursor,
} from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceSpacePermissionsAPI')
export const dynamic = 'force-dynamic'
/**
* List permissions for a Confluence space.
* Uses GET /wiki/api/v2/spaces/{id}/permissions
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceSpacePermissionsContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
spaceId,
cloudId: providedCloudId,
limit,
cursor,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/permissions?${queryParams.toString()}`
logger.info(`Fetching permissions for space ${spaceId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const permissions = (data.results || []).map((perm: any) => ({
id: perm.id,
principalType: perm.principal?.type ?? null,
principalId: perm.principal?.id ?? null,
operationKey: perm.operation?.key ?? null,
operationTargetType: perm.operation?.targetType ?? null,
anonymousAccess: perm.anonymousAccess ?? false,
unlicensedAccess: perm.unlicensedAccess ?? false,
}))
return NextResponse.json({
permissions,
spaceId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing space permissions:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,218 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceSpacePropertiesContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateJiraCloudId,
validatePaginationCursor,
} from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceSpacePropertiesAPI')
export const dynamic = 'force-dynamic'
/**
* List, create, or delete space properties.
* Uses GET/POST /wiki/api/v2/spaces/{id}/properties
* and DELETE /wiki/api/v2/spaces/{id}/properties/{propertyId}
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceSpacePropertiesContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
spaceId,
cloudId: providedCloudId,
action,
key,
value,
propertyId,
limit = 50,
cursor,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}/properties`
// Validate required params for specific actions
if (action === 'delete' && !propertyId) {
return NextResponse.json(
{ error: 'Property ID is required for delete action' },
{ status: 400 }
)
}
if (action === 'create' && !key) {
return NextResponse.json(
{ error: 'Property key is required for create action' },
{ status: 400 }
)
}
// Delete a property
if (action === 'delete' && propertyId) {
const propertyIdValidation = validateAlphanumericId(propertyId, 'propertyId', 255)
if (!propertyIdValidation.isValid) {
return NextResponse.json({ error: propertyIdValidation.error }, { status: 400 })
}
const url = `${baseUrl}/${encodeURIComponent(propertyId)}`
logger.info(`Deleting space property ${propertyId} from space ${spaceId}`)
const response = await fetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
return NextResponse.json({ spaceId, propertyId, deleted: true })
}
// Create a property
if (action === 'create' && key) {
logger.info(`Creating space property '${key}' on space ${spaceId}`)
const response = await fetch(baseUrl, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ key, value: value ?? {} }),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
propertyId: data.id,
key: data.key,
value: data.value ?? null,
spaceId,
})
}
// List properties
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}
const url = `${baseUrl}?${queryParams.toString()}`
logger.info(`Fetching properties for space ${spaceId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const properties = (data.results || []).map((prop: any) => ({
id: prop.id,
key: prop.key,
value: prop.value ?? null,
}))
return NextResponse.json({
properties,
spaceId,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error with space properties:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,416 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
confluenceCreateSpaceContract,
confluenceDeleteSpaceContract,
confluenceGetSpaceContract,
confluenceUpdateSpaceContract,
} from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceSpaceAPI')
export const dynamic = 'force-dynamic'
// Get a specific space
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceGetSpaceContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, spaceId, cloudId: providedCloudId } = parsed.data.query
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
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/spaces/${spaceId}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error getting Confluence space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
/**
* Create a new Confluence space.
* Uses POST /wiki/api/v2/spaces
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceCreateSpaceContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
name,
key,
description,
cloudId: providedCloudId,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!name) {
return NextResponse.json({ error: 'Space name is required' }, { status: 400 })
}
if (!key) {
return NextResponse.json({ error: 'Space key is required' }, { status: 400 })
}
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/spaces`
const createBody: Record<string, unknown> = { name, key }
if (description) {
createBody.description = { value: description, representation: 'plain' }
}
logger.info(`Creating space with key ${key}`)
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(createBody),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error creating Confluence space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
/**
* Update a Confluence space.
* Uses PUT /wiki/api/v2/spaces/{id}
*/
export const PUT = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceUpdateSpaceContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
spaceId,
name,
description,
cloudId: providedCloudId,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
if (!name && description === undefined) {
return NextResponse.json(
{ error: 'At least one of name or description is required for update' },
{ status: 400 }
)
}
const lookupUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}`
const lookupResponse = await fetch(lookupUrl, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!lookupResponse.ok) {
const errorText = await lookupResponse.text()
return NextResponse.json(
{
error: parseAtlassianErrorMessage(
lookupResponse.status,
lookupResponse.statusText,
errorText
),
},
{ status: lookupResponse.status }
)
}
const currentSpace = await lookupResponse.json()
const spaceKey = currentSpace.key
const updateBody: Record<string, unknown> = {
name: name || currentSpace.name,
}
if (description !== undefined) {
updateBody.description = { plain: { value: description, representation: 'plain' } }
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/space/${encodeURIComponent(spaceKey)}`
logger.info(`Updating space ${spaceKey}`)
const response = await fetch(url, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(updateBody),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error updating Confluence space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
/**
* Delete a Confluence space.
* Uses DELETE /wiki/api/v2/spaces/{id}
*/
export const DELETE = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceDeleteSpaceContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, spaceId, cloudId: providedCloudId } = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!spaceId) {
return NextResponse.json({ error: 'Space ID is required' }, { status: 400 })
}
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const lookupUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces/${spaceId}`
const lookupResponse = await fetch(lookupUrl, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!lookupResponse.ok) {
const errorText = await lookupResponse.text()
return NextResponse.json(
{
error: parseAtlassianErrorMessage(
lookupResponse.status,
lookupResponse.statusText,
errorText
),
},
{ status: lookupResponse.status }
)
}
const currentSpace = await lookupResponse.json()
const spaceKey = currentSpace.key
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/space/${encodeURIComponent(spaceKey)}`
logger.info(`Deleting space ${spaceKey}`)
const response = await fetch(url, {
method: 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
let longTask: { id?: string; statusLink?: string } = {}
try {
const text = await response.text()
if (text) {
const data = JSON.parse(text)
longTask = {
id: data?.id,
statusLink: data?.links?.status,
}
}
} catch {
// 204 No Content or non-JSON body — ignore
}
return NextResponse.json({
spaceId,
deleted: true,
longTaskId: longTask.id,
longTaskStatusLink: longTask.statusLink,
})
} catch (error) {
logger.error('Error deleting Confluence space:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,98 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceListSpacesContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceSpacesAPI')
export const dynamic = 'force-dynamic'
// List all spaces
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceListSpacesContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, cloudId: providedCloudId, limit, cursor } = parsed.data.query
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(Number(limit), 250)))
if (cursor) {
queryParams.append('cursor', cursor)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?${queryParams.toString()}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const spaces = (data.results || []).map((space: any) => ({
id: space.id,
name: space.name,
key: space.key,
type: space.type,
status: space.status,
authorId: space.authorId ?? null,
createdAt: space.createdAt ?? null,
homepageId: space.homepageId ?? null,
description: space.description ?? null,
}))
return NextResponse.json({
spaces,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error listing Confluence spaces:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,298 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceTasksContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
validateAlphanumericId,
validateJiraCloudId,
validatePaginationCursor,
validatePathSegment,
} from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceTasksAPI')
export const dynamic = 'force-dynamic'
/**
* List, get, or update Confluence inline tasks.
* Uses GET /wiki/api/v2/tasks, GET /wiki/api/v2/tasks/{id}, PUT /wiki/api/v2/tasks/{id}
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceTasksContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
cloudId: providedCloudId,
action,
taskId,
status: taskStatus,
pageId,
spaceId,
assignedTo,
limit = 50,
cursor,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
// Update a task
if (action === 'update' && taskId) {
const taskIdValidation = validateAlphanumericId(taskId, 'taskId', 255)
if (!taskIdValidation.isValid) {
return NextResponse.json({ error: taskIdValidation.error }, { status: 400 })
}
// First fetch the current task to get required fields
const getUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/tasks/${taskId}`
const getResponse = await fetch(getUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!getResponse.ok) {
const errorText = await getResponse.text()
return NextResponse.json(
{
error: parseAtlassianErrorMessage(
getResponse.status,
getResponse.statusText,
errorText
),
},
{ status: getResponse.status }
)
}
const currentTask = await getResponse.json()
const updateBody: Record<string, unknown> = {
id: taskId,
status: taskStatus || currentTask.status,
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/tasks/${taskId}`
logger.info(`Updating task ${taskId}`)
const response = await fetch(url, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(updateBody),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
task: {
id: data.id,
localId: data.localId ?? null,
spaceId: data.spaceId ?? null,
pageId: data.pageId ?? null,
blogPostId: data.blogPostId ?? null,
status: data.status,
body: data.body?.storage?.value ?? null,
createdBy: data.createdBy ?? null,
assignedTo: data.assignedTo ?? null,
completedBy: data.completedBy ?? null,
createdAt: data.createdAt ?? null,
updatedAt: data.updatedAt ?? null,
dueAt: data.dueAt ?? null,
completedAt: data.completedAt ?? null,
},
})
}
// Get a specific task
if (taskId) {
const taskIdValidation = validateAlphanumericId(taskId, 'taskId', 255)
if (!taskIdValidation.isValid) {
return NextResponse.json({ error: taskIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/tasks/${taskId}`
logger.info(`Fetching task ${taskId}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({
task: {
id: data.id,
localId: data.localId ?? null,
spaceId: data.spaceId ?? null,
pageId: data.pageId ?? null,
blogPostId: data.blogPostId ?? null,
status: data.status,
body: data.body?.storage?.value ?? null,
createdBy: data.createdBy ?? null,
assignedTo: data.assignedTo ?? null,
completedBy: data.completedBy ?? null,
createdAt: data.createdAt ?? null,
updatedAt: data.updatedAt ?? null,
dueAt: data.dueAt ?? null,
completedAt: data.completedAt ?? null,
},
})
}
// List tasks
const queryParams = new URLSearchParams()
queryParams.append('limit', String(Math.min(limit, 250)))
if (cursor) {
const cursorValidation = validatePaginationCursor(cursor, 'cursor')
if (!cursorValidation.isValid) {
return NextResponse.json({ error: cursorValidation.error }, { status: 400 })
}
queryParams.append('cursor', cursor)
}
if (taskStatus) queryParams.append('status', taskStatus)
if (pageId) {
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
queryParams.append('page-id', pageId)
}
if (spaceId) {
const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId', 255)
if (!spaceIdValidation.isValid) {
return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 })
}
queryParams.append('space-id', spaceId)
}
if (assignedTo) {
// Atlassian account IDs: 5d5bd05c3aee0123abc or 557058:6b9c9931-4693-49c1-8b3a-931f1af98134
const assignedToValidation = validatePathSegment(assignedTo, {
paramName: 'assignedTo',
maxLength: 128,
customPattern: /^[a-zA-Z0-9_|:-]+$/,
})
if (!assignedToValidation.isValid) {
return NextResponse.json({ error: assignedToValidation.error }, { status: 400 })
}
queryParams.append('assigned-to', assignedTo)
}
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/tasks?${queryParams.toString()}`
logger.info('Fetching tasks')
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const tasks = (data.results || []).map((task: any) => ({
id: task.id,
localId: task.localId ?? null,
spaceId: task.spaceId ?? null,
pageId: task.pageId ?? null,
blogPostId: task.blogPostId ?? null,
status: task.status,
body: task.body?.storage?.value ?? null,
createdBy: task.createdBy ?? null,
assignedTo: task.assignedTo ?? null,
completedBy: task.completedBy ?? null,
createdAt: task.createdAt ?? null,
updatedAt: task.updatedAt ?? null,
dueAt: task.dueAt ?? null,
completedAt: task.completedAt ?? null,
}))
return NextResponse.json({
tasks,
nextCursor: data._links?.next
? new URL(data._links.next, 'https://placeholder').searchParams.get('cursor')
: null,
})
} catch (error) {
logger.error('Error with tasks:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,169 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceUploadAttachmentContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processSingleFileToUserFile, type RawFileInput } from '@/lib/uploads/utils/file-utils'
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceUploadAttachmentAPI')
export const dynamic = 'force-dynamic'
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceUploadAttachmentContract, request, {})
if (!parsed.success) return parsed.response
const {
domain,
accessToken,
cloudId: providedCloudId,
pageId,
file,
fileName,
comment,
} = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!pageId) {
return NextResponse.json({ error: 'Page ID is required' }, { status: 400 })
}
if (!file) {
return NextResponse.json({ error: 'File is required' }, { status: 400 })
}
const pageIdValidation = validateAlphanumericId(pageId, 'pageId', 255)
if (!pageIdValidation.isValid) {
return NextResponse.json({ error: pageIdValidation.error }, { status: 400 })
}
const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken))
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
let fileToProcess = file as RawFileInput
if (Array.isArray(file)) {
if (file.length === 0) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
fileToProcess = file[0] as RawFileInput
}
let userFile
try {
userFile = processSingleFileToUserFile(fileToProcess, 'confluence-upload', logger)
} catch (error) {
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to process file') },
{ status: 400 }
)
}
const denied = await assertToolFileAccess(
userFile.key,
auth.userId,
'confluence-upload',
logger
)
if (denied) return denied
let fileBuffer: Buffer
let resolvedContentType: string
try {
const servable = await downloadServableFileFromStorage(userFile, 'confluence-upload', logger)
fileBuffer = servable.buffer
resolvedContentType = servable.contentType
} catch (error) {
const notReady = docNotReadyResponse(error)
if (notReady) return notReady
logger.error('Failed to download file from storage:', error)
return NextResponse.json(
{
error: `Failed to download file: ${getErrorMessage(error, 'Unknown error')}`,
},
{ status: 500 }
)
}
const uploadFileName = fileName || userFile.name || 'attachment'
const mimeType = resolvedContentType || userFile.type || 'application/octet-stream'
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/rest/api/content/${pageId}/child/attachment`
const formData = new FormData()
const blob = new Blob([new Uint8Array(fileBuffer)], { type: mimeType })
formData.append('file', blob, uploadFileName)
if (comment) {
formData.append('comment', comment)
}
// Add minorEdit field as required by Confluence API
formData.append('minorEdit', 'false')
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'X-Atlassian-Token': 'nocheck',
},
body: formData,
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
const attachment = data.results?.[0] || data
return NextResponse.json({
attachmentId: attachment.id,
title: attachment.title,
fileSize: attachment.extensions?.fileSize || 0,
mediaType: attachment.extensions?.mediaType || mimeType,
downloadUrl: attachment._links?.download || '',
pageId: pageId,
})
} catch (error) {
logger.error('Error uploading Confluence attachment:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})
@@ -0,0 +1,92 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { confluenceUserContract } from '@/lib/api/contracts/selectors/confluence'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validatePathSegment } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('ConfluenceUserAPI')
export const dynamic = 'force-dynamic'
/**
* Get a Confluence user by account ID.
* Uses GET /wiki/rest/api/user?accountId={accountId}
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(confluenceUserContract, request, {})
if (!parsed.success) return parsed.response
const { domain, accessToken, accountId, cloudId: providedCloudId } = parsed.data.body
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!accountId) {
return NextResponse.json({ error: 'Account ID is required' }, { status: 400 })
}
// Atlassian account IDs: 5d5bd05c3aee0123abc or 557058:6b9c9931-4693-49c1-8b3a-931f1af98134
const accountIdValidation = validatePathSegment(accountId, {
paramName: 'accountId',
maxLength: 128,
customPattern: /^[a-zA-Z0-9_|:-]+$/,
})
if (!accountIdValidation.isValid) {
return NextResponse.json({ error: accountIdValidation.error }, { status: 400 })
}
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/rest/api/user?accountId=${encodeURIComponent(accountId)}`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Confluence API error response:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
logger.error('Error getting Confluence user:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
})