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
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:
@@ -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 }
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user