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,60 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createA2AClient, taskOutput } from '@/lib/a2a/client'
|
||||
import { a2aCancelTaskContract } from '@/lib/api/contracts/tools/a2a'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 60
|
||||
|
||||
const logger = createLogger('A2ACancelTaskAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: auth.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const rateLimited = await enforceUserOrIpRateLimit('a2a-cancel-task', auth.userId, request)
|
||||
if (rateLimited) return rateLimited
|
||||
|
||||
const parsed = await parseRequest(
|
||||
a2aCancelTaskContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json(
|
||||
{ success: false, error: getValidationErrorMessage(error, 'Invalid request data') },
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const body = parsed.data.body
|
||||
|
||||
try {
|
||||
const client = await createA2AClient(body.agentUrl, body.apiKey, { signal: request.signal })
|
||||
const task = await client.cancelTask({ tenant: '', id: body.taskId, metadata: undefined })
|
||||
const out = taskOutput(task)
|
||||
|
||||
logger.info(`[${requestId}] Cancel requested for A2A task ${task.id}`)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { taskId: out.taskId, state: out.state, canceled: out.state === 'canceled' },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] A2A cancel-task failed`, { error: getErrorMessage(error) })
|
||||
return NextResponse.json({ success: false, error: getErrorMessage(error) }, { status: 502 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agentCardOutput, createA2AClient } from '@/lib/a2a/client'
|
||||
import { a2aGetAgentCardContract } from '@/lib/api/contracts/tools/a2a'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 60
|
||||
|
||||
const logger = createLogger('A2AGetAgentCardAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: auth.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const rateLimited = await enforceUserOrIpRateLimit('a2a-get-agent-card', auth.userId, request)
|
||||
if (rateLimited) return rateLimited
|
||||
|
||||
const parsed = await parseRequest(
|
||||
a2aGetAgentCardContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json(
|
||||
{ success: false, error: getValidationErrorMessage(error, 'Invalid request data') },
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const body = parsed.data.body
|
||||
|
||||
try {
|
||||
const client = await createA2AClient(body.agentUrl, body.apiKey, { signal: request.signal })
|
||||
const card = await client.getAgentCard()
|
||||
|
||||
logger.info(`[${requestId}] Fetched agent card for ${card.name}`)
|
||||
return NextResponse.json({ success: true, output: agentCardOutput(card, body.agentUrl) })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] A2A get-agent-card failed`, { error: getErrorMessage(error) })
|
||||
return NextResponse.json({ success: false, error: getErrorMessage(error) }, { status: 502 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createA2AClient, taskOutput } from '@/lib/a2a/client'
|
||||
import { a2aGetTaskContract } from '@/lib/api/contracts/tools/a2a'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 60
|
||||
|
||||
const logger = createLogger('A2AGetTaskAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: auth.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const rateLimited = await enforceUserOrIpRateLimit('a2a-get-task', auth.userId, request)
|
||||
if (rateLimited) return rateLimited
|
||||
|
||||
const parsed = await parseRequest(
|
||||
a2aGetTaskContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json(
|
||||
{ success: false, error: getValidationErrorMessage(error, 'Invalid request data') },
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const body = parsed.data.body
|
||||
|
||||
try {
|
||||
const client = await createA2AClient(body.agentUrl, body.apiKey, { signal: request.signal })
|
||||
const task = await client.getTask({
|
||||
tenant: '',
|
||||
id: body.taskId,
|
||||
historyLength: body.historyLength,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Retrieved A2A task ${task.id}`)
|
||||
return NextResponse.json({ success: true, output: taskOutput(task) })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] A2A get-task failed`, { error: getErrorMessage(error) })
|
||||
return NextResponse.json({ success: false, error: getErrorMessage(error) }, { status: 502 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
type A2AFileInput,
|
||||
buildUserMessage,
|
||||
createA2AClient,
|
||||
isTaskResult,
|
||||
messageOutput,
|
||||
taskErrored,
|
||||
taskOutput,
|
||||
} from '@/lib/a2a/client'
|
||||
import { a2aSendMessageContract } from '@/lib/api/contracts/tools/a2a'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { enforceUserOrIpRateLimit } from '@/lib/core/rate-limiter'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { processFilesToUserFiles } 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'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** Blocking sends wait until the agent reaches a terminal/interrupted state. */
|
||||
export const maxDuration = 300
|
||||
|
||||
/** Per-file cap on attachments resolved from storage. */
|
||||
const A2A_MAX_FILE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
const logger = createLogger('A2ASendMessageAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: auth.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const rateLimited = await enforceUserOrIpRateLimit('a2a-send-message', auth.userId, request)
|
||||
if (rateLimited) return rateLimited
|
||||
|
||||
const parsed = await parseRequest(
|
||||
a2aSendMessageContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json(
|
||||
{ success: false, error: getValidationErrorMessage(error, 'Invalid request data') },
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const body = parsed.data.body
|
||||
|
||||
let data: unknown
|
||||
if (body.data !== undefined) {
|
||||
if (typeof body.data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(body.data)
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Data must be valid JSON' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
data = body.data
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let files: A2AFileInput[] | undefined
|
||||
if (body.files?.length) {
|
||||
if (!auth.userId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Authentication required to attach files' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
const userFiles = processFilesToUserFiles(body.files, requestId, logger)
|
||||
for (const userFile of userFiles) {
|
||||
const denied = await assertToolFileAccess(userFile.key, auth.userId, requestId, logger)
|
||||
if (denied) return denied
|
||||
}
|
||||
files = await Promise.all(
|
||||
userFiles.map(async (userFile) => {
|
||||
const { buffer, contentType } = await downloadServableFileFromStorage(
|
||||
userFile,
|
||||
requestId,
|
||||
logger,
|
||||
{ maxBytes: A2A_MAX_FILE_BYTES }
|
||||
)
|
||||
return {
|
||||
bytes: buffer,
|
||||
name: userFile.name,
|
||||
mediaType: contentType || userFile.type || 'application/octet-stream',
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const client = await createA2AClient(body.agentUrl, body.apiKey, { signal: request.signal })
|
||||
const message = buildUserMessage({
|
||||
text: body.message,
|
||||
data,
|
||||
files,
|
||||
taskId: body.taskId,
|
||||
contextId: body.contextId,
|
||||
})
|
||||
|
||||
const result = await client.sendMessage({
|
||||
tenant: '',
|
||||
message,
|
||||
configuration: undefined,
|
||||
metadata: undefined,
|
||||
})
|
||||
|
||||
if (!isTaskResult(result)) {
|
||||
logger.info(`[${requestId}] A2A send returned a direct message`)
|
||||
return NextResponse.json({ success: true, output: messageOutput(result) })
|
||||
}
|
||||
|
||||
const output = taskOutput(result)
|
||||
const errored = taskErrored(result)
|
||||
logger.info(`[${requestId}] A2A send produced task ${result.id} (${output.state})`)
|
||||
return NextResponse.json({
|
||||
success: !errored,
|
||||
...(errored ? { error: output.content || `Agent task ${output.state}` } : {}),
|
||||
output,
|
||||
})
|
||||
} catch (error) {
|
||||
const notReady = docNotReadyResponse(error)
|
||||
if (notReady) return notReady
|
||||
logger.error(`[${requestId}] A2A send failed`, { error: getErrorMessage(error) })
|
||||
return NextResponse.json({ success: false, error: getErrorMessage(error) }, { status: 502 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
createMockRequest,
|
||||
hybridAuthMockFns,
|
||||
inputValidationMock,
|
||||
inputValidationMockFns,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockProcessFilesToUserFiles, mockDownloadFileFromStorage, mockAssertToolFileAccess } =
|
||||
vi.hoisted(() => ({
|
||||
mockProcessFilesToUserFiles: vi.fn(),
|
||||
mockDownloadFileFromStorage: vi.fn(),
|
||||
mockAssertToolFileAccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
|
||||
vi.mock('@/lib/uploads/utils/file-utils', () => ({
|
||||
processFilesToUserFiles: mockProcessFilesToUserFiles,
|
||||
}))
|
||||
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
|
||||
downloadServableFileFromStorage: mockDownloadFileFromStorage,
|
||||
}))
|
||||
vi.mock('@/app/api/files/authorization', () => ({
|
||||
assertToolFileAccess: mockAssertToolFileAccess,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/tools/agiloft/attach/route'
|
||||
|
||||
const PINNED_IP = '93.184.216.34'
|
||||
|
||||
const baseBody = {
|
||||
instanceUrl: 'https://example.agiloft.com',
|
||||
knowledgeBase: 'demo',
|
||||
login: 'admin',
|
||||
password: 'secret',
|
||||
table: 'contracts',
|
||||
recordId: '42',
|
||||
fieldName: 'attachments',
|
||||
file: { key: 's3://bucket/file.txt', name: 'file.txt', size: 5, type: 'text/plain' },
|
||||
fileName: 'file.txt',
|
||||
}
|
||||
|
||||
function mockSecureFetchResponse(body: {
|
||||
ok?: boolean
|
||||
status?: number
|
||||
json?: unknown
|
||||
text?: string
|
||||
}) {
|
||||
return {
|
||||
ok: body.ok ?? true,
|
||||
status: body.status ?? 200,
|
||||
statusText: '',
|
||||
headers: new Headers(),
|
||||
body: null,
|
||||
text: async () => body.text ?? '',
|
||||
json: async () => body.json ?? {},
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'internal_jwt',
|
||||
})
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: PINNED_IP,
|
||||
originalHostname: 'example.agiloft.com',
|
||||
})
|
||||
mockProcessFilesToUserFiles.mockReturnValue([
|
||||
{ key: 's3://bucket/file.txt', name: 'file.txt', size: 5, type: 'text/plain' },
|
||||
])
|
||||
mockAssertToolFileAccess.mockResolvedValue(null)
|
||||
mockDownloadFileFromStorage.mockResolvedValue({
|
||||
buffer: Buffer.from('hello'),
|
||||
contentType: 'application/octet-stream',
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/tools/agiloft/attach', () => {
|
||||
it('rejects unauthenticated requests', async () => {
|
||||
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'unauthorized',
|
||||
})
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(401)
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks SSRF when the instance URL fails DNS validation', async () => {
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValueOnce({
|
||||
isValid: false,
|
||||
error: 'instanceUrl resolves to a blocked IP address',
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('POST', { ...baseBody, instanceUrl: 'https://attacker.example.com' })
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pins the resolved IP for login, attach, and logout (TOCTOU fix)', async () => {
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-att' } }))
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ text: '1' }))
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({}))
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(200)
|
||||
const data = (await response.json()) as {
|
||||
success: true
|
||||
output: { totalAttachments: number; fileName: string }
|
||||
}
|
||||
expect(data.output.totalAttachments).toBe(1)
|
||||
expect(data.output.fileName).toBe('file.txt')
|
||||
|
||||
const calls = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls
|
||||
expect(calls).toHaveLength(3)
|
||||
for (const call of calls) {
|
||||
expect(call[1]).toBe(PINNED_IP)
|
||||
}
|
||||
|
||||
expect(calls[0][0]).toContain('https://example.agiloft.com/ewws/EWLogin')
|
||||
expect(calls[1][0]).toContain('https://example.agiloft.com/ewws/EWAttach')
|
||||
expect(calls[1][2]).toMatchObject({
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: 'Bearer tok-att',
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
})
|
||||
expect(calls[2][0]).toContain('https://example.agiloft.com/ewws/EWLogout')
|
||||
|
||||
// DNS only resolved once.
|
||||
expect(inputValidationMockFns.mockValidateUrlWithDNS).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftAttachContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { RawFileInput } from '@/lib/uploads/utils/file-schemas'
|
||||
import { processFilesToUserFiles } 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 { buildAttachFileUrl } from '@/tools/agiloft/utils'
|
||||
import {
|
||||
agiloftLoginPinned,
|
||||
agiloftLogoutPinned,
|
||||
resolveAgiloftInstance,
|
||||
} from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftAttachAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft attach attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftAttachContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
if (!data.file) {
|
||||
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userFiles = processFilesToUserFiles([data.file as RawFileInput], requestId, logger)
|
||||
|
||||
if (userFiles.length === 0) {
|
||||
return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userFile = userFiles[0]
|
||||
logger.info(
|
||||
`[${requestId}] Downloading file for Agiloft attach: ${userFile.name} (${userFile.size} bytes)`
|
||||
)
|
||||
|
||||
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
|
||||
if (denied) return denied
|
||||
|
||||
let fileBuffer: Buffer
|
||||
try {
|
||||
const servable = await downloadServableFileFromStorage(userFile, requestId, logger)
|
||||
fileBuffer = servable.buffer
|
||||
} catch (error) {
|
||||
const notReady = docNotReadyResponse(error)
|
||||
if (notReady) return notReady
|
||||
logger.error(`[${requestId}] Failed to download file from storage:`, error)
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
|
||||
const resolvedFileName = data.fileName || userFile.name || 'attachment'
|
||||
|
||||
let resolvedIP: string
|
||||
try {
|
||||
resolvedIP = await resolveAgiloftInstance(data.instanceUrl)
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] SSRF attempt blocked for Agiloft instance URL`, {
|
||||
instanceUrl: data.instanceUrl,
|
||||
})
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = await agiloftLoginPinned(data, resolvedIP)
|
||||
const base = data.instanceUrl.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
const url = buildAttachFileUrl(base, data, resolvedFileName)
|
||||
|
||||
logger.info(`[${requestId}] Uploading file to Agiloft: ${resolvedFileName}`)
|
||||
|
||||
const agiloftResponse = await secureFetchWithPinnedIP(url, resolvedIP, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: new Uint8Array(fileBuffer),
|
||||
})
|
||||
|
||||
if (!agiloftResponse.ok) {
|
||||
const errorText = await agiloftResponse.text()
|
||||
logger.error(
|
||||
`[${requestId}] Agiloft attach error: ${agiloftResponse.status} - ${errorText}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Agiloft error: ${agiloftResponse.status} - ${errorText}` },
|
||||
{ status: agiloftResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
let totalAttachments = 0
|
||||
const responseText = await agiloftResponse.text()
|
||||
try {
|
||||
const responseData = JSON.parse(responseText)
|
||||
const result = responseData.result ?? responseData
|
||||
totalAttachments = typeof result === 'number' ? result : (result.count ?? result.total ?? 1)
|
||||
} catch {
|
||||
totalAttachments = Number(responseText) || 1
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] File attached successfully. Total attachments: ${totalAttachments}`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
recordId: data.recordId.trim(),
|
||||
fieldName: data.fieldName.trim(),
|
||||
fileName: resolvedFileName,
|
||||
totalAttachments,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await agiloftLogoutPinned(data.instanceUrl, data.knowledgeBase, token, resolvedIP)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error attaching file to Agiloft:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftAttachmentInfoContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftAttachmentInfoResponse } from '@/tools/agiloft/types'
|
||||
import { buildAttachmentInfoUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftAttachmentInfoAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Unauthorized Agiloft attachment_info attempt: ${authResult.error}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftAttachmentInfoContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftAttachmentInfoResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildAttachmentInfoUrl(base, params),
|
||||
method: 'GET',
|
||||
}),
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
output: { attachments: [], totalCount: 0 },
|
||||
error: `Agiloft error: ${response.status} - ${errorText}`,
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
const result = (data.result ?? data) as Record<string, unknown>
|
||||
|
||||
const attachments: Array<{ position: number; name: string; size: number }> = []
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const item = result[i] as Record<string, unknown>
|
||||
attachments.push({
|
||||
position: (item.filePosition as number) ?? (item.position as number) ?? i,
|
||||
name:
|
||||
(item.fileName as string) ??
|
||||
(item.name as string) ??
|
||||
(item.filename as string) ??
|
||||
'',
|
||||
size: (item.size as number) ?? (item.fileSize as number) ?? 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: data.success !== false,
|
||||
output: {
|
||||
attachments,
|
||||
totalCount: attachments.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting Agiloft attachment info:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftCreateRecordContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
|
||||
import { buildCreateRecordUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftCreateRecordAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft create_record attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftCreateRecordContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
let body: string
|
||||
try {
|
||||
body = JSON.stringify(JSON.parse(params.data))
|
||||
} catch {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: 'Invalid JSON in data parameter',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildCreateRecordUrl(base, params),
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body,
|
||||
}),
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: `Agiloft error: ${response.status} - ${errorText}`,
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
const result = (data.result ?? data) as Record<string, unknown>
|
||||
const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null
|
||||
|
||||
return {
|
||||
success: data.success !== false,
|
||||
output: {
|
||||
id: id != null ? String(id) : null,
|
||||
fields: result ?? {},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error creating Agiloft record:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftDeleteRecordContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftDeleteResponse } from '@/tools/agiloft/types'
|
||||
import { buildDeleteRecordUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftDeleteRecordAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft delete_record attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftDeleteRecordContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftDeleteResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildDeleteRecordUrl(base, params),
|
||||
method: 'DELETE',
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
output: { id: params.recordId?.trim() ?? '', deleted: false },
|
||||
error: `Agiloft error: ${response.status} - ${errorText}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
id: params.recordId?.trim() ?? '',
|
||||
deleted: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting Agiloft record:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftGetChoiceLineIdContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftGetChoiceLineIdResponse } from '@/tools/agiloft/types'
|
||||
import { buildGetChoiceLineIdUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftGetChoiceLineIdAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Unauthorized Agiloft get_choice_line_id attempt: ${authResult.error}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftGetChoiceLineIdContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftGetChoiceLineIdResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildGetChoiceLineIdUrl(base, params),
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
output: { choiceLineId: null },
|
||||
error: `Agiloft error: ${response.status} - ${errorText}`,
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
const result = data.result ?? data
|
||||
let choiceLineId: number | null = null
|
||||
|
||||
if (typeof result === 'number') {
|
||||
choiceLineId = result
|
||||
} else if (typeof result === 'string') {
|
||||
const parsed = Number(result)
|
||||
choiceLineId = Number.isFinite(parsed) ? parsed : null
|
||||
} else if (typeof result === 'object' && result !== null) {
|
||||
const obj = result as Record<string, unknown>
|
||||
const idVal = obj.id ?? obj.choiceLineId ?? obj.lineId
|
||||
if (typeof idVal === 'number') {
|
||||
choiceLineId = idVal
|
||||
} else if (typeof idVal === 'string') {
|
||||
const parsed = Number(idVal)
|
||||
choiceLineId = Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
}
|
||||
|
||||
if (choiceLineId === null) {
|
||||
return {
|
||||
success: false,
|
||||
output: { choiceLineId: null },
|
||||
error: `No choice line ID found for value "${params.value}" in field "${params.fieldName}"`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: data.success !== false,
|
||||
output: { choiceLineId },
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting Agiloft choice line ID:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftLockRecordContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftLockResponse } from '@/tools/agiloft/types'
|
||||
import { buildLockRecordUrl, getLockHttpMethod } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftLockRecordAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft lock_record attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftLockRecordContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftLockResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildLockRecordUrl(base, params),
|
||||
method: getLockHttpMethod(params.lockAction),
|
||||
}),
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
id: params.recordId?.trim() ?? '',
|
||||
lockStatus: 'UNKNOWN',
|
||||
lockedBy: null,
|
||||
lockExpiresInMinutes: null,
|
||||
},
|
||||
error: `Agiloft error: ${response.status} - ${errorText}`,
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
const result = (data.result ?? data) as Record<string, unknown>
|
||||
|
||||
return {
|
||||
success: data.success !== false,
|
||||
output: {
|
||||
id: String(result.id ?? params.recordId?.trim() ?? ''),
|
||||
lockStatus:
|
||||
(result.lock_status as string) ?? (result.lockStatus as string) ?? 'UNKNOWN',
|
||||
lockedBy:
|
||||
(result.locked_by as string | null) ?? (result.lockedBy as string | null) ?? null,
|
||||
lockExpiresInMinutes:
|
||||
(result.lock_expires_in_minutes as number | null) ??
|
||||
(result.lockExpiresInMinutes as number | null) ??
|
||||
null,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error locking Agiloft record:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftReadRecordContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
|
||||
import { buildReadRecordUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftReadRecordAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft read_record attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftReadRecordContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildReadRecordUrl(base, params),
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: `Agiloft error: ${response.status} - ${errorText}`,
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
const result = (data.result ?? data) as Record<string, unknown>
|
||||
const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null
|
||||
|
||||
return {
|
||||
success: data.success !== false,
|
||||
output: {
|
||||
id: id != null ? String(id) : null,
|
||||
fields: result ?? {},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error reading Agiloft record:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftRemoveAttachmentContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftRemoveAttachmentResponse } from '@/tools/agiloft/types'
|
||||
import { buildRemoveAttachmentUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftRemoveAttachmentAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Unauthorized Agiloft remove_attachment attempt: ${authResult.error}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftRemoveAttachmentContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftRemoveAttachmentResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildRemoveAttachmentUrl(base, params),
|
||||
method: 'DELETE',
|
||||
}),
|
||||
async (response) => {
|
||||
const text = await response.text()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
recordId: params.recordId?.trim() ?? '',
|
||||
fieldName: params.fieldName?.trim() ?? '',
|
||||
remainingAttachments: 0,
|
||||
},
|
||||
error: `Agiloft error: ${response.status} - ${text}`,
|
||||
}
|
||||
}
|
||||
|
||||
let remainingAttachments = 0
|
||||
try {
|
||||
const data = JSON.parse(text)
|
||||
const result = data.result ?? data
|
||||
remainingAttachments =
|
||||
typeof result === 'number' ? result : (result.count ?? result.remaining ?? 0)
|
||||
} catch {
|
||||
remainingAttachments = Number(text) || 0
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
recordId: params.recordId?.trim() ?? '',
|
||||
fieldName: params.fieldName?.trim() ?? '',
|
||||
remainingAttachments,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error removing Agiloft attachment:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
createMockRequest,
|
||||
hybridAuthMockFns,
|
||||
inputValidationMock,
|
||||
inputValidationMockFns,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
|
||||
|
||||
import { POST } from '@/app/api/tools/agiloft/retrieve/route'
|
||||
|
||||
const PINNED_IP = '93.184.216.34'
|
||||
|
||||
const baseBody = {
|
||||
instanceUrl: 'https://example.agiloft.com',
|
||||
knowledgeBase: 'demo',
|
||||
login: 'admin',
|
||||
password: 'secret',
|
||||
table: 'contracts',
|
||||
recordId: '42',
|
||||
fieldName: 'attachments',
|
||||
position: '0',
|
||||
}
|
||||
|
||||
function mockSecureFetchResponse(body: {
|
||||
ok?: boolean
|
||||
status?: number
|
||||
json?: unknown
|
||||
text?: string
|
||||
arrayBuffer?: ArrayBuffer
|
||||
headers?: Headers
|
||||
}) {
|
||||
return {
|
||||
ok: body.ok ?? true,
|
||||
status: body.status ?? 200,
|
||||
statusText: '',
|
||||
headers: body.headers ?? new Headers(),
|
||||
body: null,
|
||||
text: async () => body.text ?? '',
|
||||
json: async () => body.json ?? {},
|
||||
arrayBuffer: async () => body.arrayBuffer ?? new ArrayBuffer(0),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'internal_jwt',
|
||||
})
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: PINNED_IP,
|
||||
originalHostname: 'example.agiloft.com',
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/tools/agiloft/retrieve', () => {
|
||||
it('rejects unauthenticated requests', async () => {
|
||||
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'unauthorized',
|
||||
})
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(401)
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks SSRF when the instance URL fails DNS validation', async () => {
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValueOnce({
|
||||
isValid: false,
|
||||
error: 'instanceUrl resolves to a blocked IP address',
|
||||
})
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('POST', { ...baseBody, instanceUrl: 'https://attacker.example.com' })
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = (await response.json()) as { success: false; error: string }
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('blocked IP')
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pins the resolved IP for login, retrieve, and logout (TOCTOU fix)', async () => {
|
||||
const fileBytes = Buffer.from('hello-attachment', 'utf-8')
|
||||
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-xyz' } }))
|
||||
.mockResolvedValueOnce(
|
||||
mockSecureFetchResponse({
|
||||
arrayBuffer: fileBytes.buffer.slice(
|
||||
fileBytes.byteOffset,
|
||||
fileBytes.byteOffset + fileBytes.byteLength
|
||||
) as ArrayBuffer,
|
||||
headers: new Headers({
|
||||
'content-type': 'text/plain',
|
||||
'content-disposition': 'attachment; filename="report.txt"',
|
||||
}),
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({}))
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(200)
|
||||
const data = (await response.json()) as {
|
||||
success: true
|
||||
output: { file: { name: string; mimeType: string; data: string; size: number } }
|
||||
}
|
||||
|
||||
expect(data.output.file.name).toBe('report.txt')
|
||||
expect(data.output.file.mimeType).toBe('text/plain')
|
||||
expect(data.output.file.size).toBe(fileBytes.length)
|
||||
expect(Buffer.from(data.output.file.data, 'base64').toString('utf-8')).toBe('hello-attachment')
|
||||
|
||||
const calls = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls
|
||||
expect(calls).toHaveLength(3)
|
||||
|
||||
// All three outbound calls must use the pre-resolved IP.
|
||||
for (const call of calls) {
|
||||
expect(call[1]).toBe(PINNED_IP)
|
||||
}
|
||||
|
||||
// Original hostname is preserved in the URL (so TLS SNI works).
|
||||
expect(calls[0][0]).toContain('https://example.agiloft.com/ewws/EWLogin')
|
||||
expect(calls[1][0]).toContain('https://example.agiloft.com/ewws/EWRetrieve')
|
||||
expect(calls[1][2]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer tok-xyz' },
|
||||
})
|
||||
expect(calls[2][0]).toContain('https://example.agiloft.com/ewws/EWLogout')
|
||||
|
||||
// DNS only resolved once — no second lookup that could rebind.
|
||||
expect(inputValidationMockFns.mockValidateUrlWithDNS).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('propagates upstream errors and still calls logout', async () => {
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-err' } }))
|
||||
.mockResolvedValueOnce(
|
||||
mockSecureFetchResponse({ ok: false, status: 404, text: 'Record not found' })
|
||||
)
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({}))
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(404)
|
||||
const data = (await response.json()) as { success: false; error: string }
|
||||
expect(data.error).toContain('Record not found')
|
||||
|
||||
// Logout still runs.
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(3)
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[2][0]).toContain(
|
||||
'/ewws/EWLogout'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftRetrieveContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { buildRetrieveAttachmentUrl } from '@/tools/agiloft/utils'
|
||||
import {
|
||||
agiloftLoginPinned,
|
||||
agiloftLogoutPinned,
|
||||
resolveAgiloftInstance,
|
||||
} from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftRetrieveAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft retrieve attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftRetrieveContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
let resolvedIP: string
|
||||
try {
|
||||
resolvedIP = await resolveAgiloftInstance(data.instanceUrl)
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] SSRF attempt blocked for Agiloft instance URL`, {
|
||||
instanceUrl: data.instanceUrl,
|
||||
})
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = await agiloftLoginPinned(data, resolvedIP)
|
||||
const base = data.instanceUrl.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
const url = buildRetrieveAttachmentUrl(base, data)
|
||||
|
||||
logger.info(`[${requestId}] Downloading attachment from Agiloft`, {
|
||||
recordId: data.recordId,
|
||||
fieldName: data.fieldName,
|
||||
position: data.position,
|
||||
})
|
||||
|
||||
const agiloftResponse = await secureFetchWithPinnedIP(url, resolvedIP, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!agiloftResponse.ok) {
|
||||
const errorText = await agiloftResponse.text()
|
||||
logger.error(
|
||||
`[${requestId}] Agiloft retrieve error: ${agiloftResponse.status} - ${errorText}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Agiloft error: ${agiloftResponse.status} - ${errorText}` },
|
||||
{ status: agiloftResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
const contentType = agiloftResponse.headers.get('content-type') || 'application/octet-stream'
|
||||
const contentDisposition = agiloftResponse.headers.get('content-disposition')
|
||||
let fileName = 'attachment'
|
||||
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||||
if (match?.[1]) {
|
||||
fileName = match[1].replace(/['"]/g, '')
|
||||
}
|
||||
}
|
||||
|
||||
const arrayBuffer = await agiloftResponse.arrayBuffer()
|
||||
const fileBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
logger.info(`[${requestId}] Attachment downloaded successfully`, {
|
||||
name: fileName,
|
||||
size: fileBuffer.length,
|
||||
mimeType: contentType,
|
||||
})
|
||||
|
||||
const base64Data = fileBuffer.toString('base64')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
file: {
|
||||
name: fileName,
|
||||
mimeType: contentType,
|
||||
data: base64Data,
|
||||
size: fileBuffer.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await agiloftLogoutPinned(data.instanceUrl, data.knowledgeBase, token, resolvedIP)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error retrieving Agiloft attachment:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftSavedSearchContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftSavedSearchResponse } from '@/tools/agiloft/types'
|
||||
import { buildSavedSearchUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftSavedSearchAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft saved_search attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftSavedSearchContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftSavedSearchResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildSavedSearchUrl(base, params),
|
||||
method: 'GET',
|
||||
}),
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
output: { searches: [] },
|
||||
error: `Agiloft error: ${response.status} - ${errorText}`,
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
const result = (data.result ?? data) as Record<string, unknown>
|
||||
|
||||
const searches: Array<{
|
||||
name: string
|
||||
label: string
|
||||
id: string | number
|
||||
description: string | null
|
||||
}> = []
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
for (const item of result as Record<string, unknown>[]) {
|
||||
searches.push({
|
||||
name: (item.name as string) ?? '',
|
||||
label: (item.label as string) ?? (item.name as string) ?? '',
|
||||
id: (item.id as string | number) ?? (item.ID as string | number) ?? '',
|
||||
description: (item.description as string | null) ?? null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: data.success !== false,
|
||||
output: {
|
||||
searches,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error listing Agiloft saved searches:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftSearchRecordsContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftSearchResponse } from '@/tools/agiloft/types'
|
||||
import { buildSearchRecordsUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftSearchRecordsAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft search_records attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftSearchRecordsContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftSearchResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildSearchRecordsUrl(base, params),
|
||||
method: 'GET',
|
||||
}),
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
output: { records: [], totalCount: 0, page: 0, limit: 25 },
|
||||
error: `Agiloft error: ${response.status} - ${errorText}`,
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
const records: Record<string, unknown>[] = []
|
||||
const result = (data.result ?? data) as Record<string, unknown>
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
for (const item of result as Record<string, unknown>[]) {
|
||||
records.push(item)
|
||||
}
|
||||
} else {
|
||||
const lengthRaw = result.EWREST_length ?? data.EWREST_length
|
||||
const count = typeof lengthRaw === 'string' ? Number(lengthRaw) : (lengthRaw as number)
|
||||
if (typeof count === 'number' && Number.isFinite(count)) {
|
||||
const source = (result.EWREST_length != null ? result : data) as Record<string, unknown>
|
||||
for (let i = 0; i < count; i++) {
|
||||
const record: Record<string, unknown> = {}
|
||||
for (const key of Object.keys(source)) {
|
||||
const match = key.match(/^EWREST_(.+)_(\d+)$/)
|
||||
if (match && Number(match[2]) === i) {
|
||||
record[match[1]] = source[key]
|
||||
}
|
||||
}
|
||||
if (Object.keys(record).length > 0) {
|
||||
records.push(record)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalCountRaw =
|
||||
result.totalCount ??
|
||||
result.total ??
|
||||
result.count ??
|
||||
result.EWREST_length ??
|
||||
data.totalCount ??
|
||||
data.total ??
|
||||
data.count ??
|
||||
data.EWREST_length ??
|
||||
records.length
|
||||
const totalCount =
|
||||
typeof totalCountRaw === 'string' ? Number(totalCountRaw) : (totalCountRaw as number)
|
||||
const page = params.page ? Number(params.page) : 0
|
||||
const limit = params.limit ? Number(params.limit) : 25
|
||||
|
||||
return {
|
||||
success: data.success !== false,
|
||||
output: {
|
||||
records,
|
||||
totalCount,
|
||||
page,
|
||||
limit,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error searching Agiloft records:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftSelectRecordsContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftSelectResponse } from '@/tools/agiloft/types'
|
||||
import { buildSelectRecordsUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftSelectRecordsAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft select_records attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftSelectRecordsContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftSelectResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildSelectRecordsUrl(base, params),
|
||||
method: 'GET',
|
||||
}),
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
output: { recordIds: [], totalCount: 0 },
|
||||
error: `Agiloft error: ${response.status} - ${errorText}`,
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
const result = (data.result ?? data) as Record<string, unknown>
|
||||
const recordIds: string[] = []
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
for (const item of result as Record<string, unknown>[]) {
|
||||
const id = item.id ?? item.ID ?? item
|
||||
recordIds.push(String(id))
|
||||
}
|
||||
} else if (typeof result === 'object' && result !== null) {
|
||||
let i = 0
|
||||
while (result[`id_${i}`] !== undefined || result[`EWREST_id_${i}`] !== undefined) {
|
||||
const id = result[`id_${i}`] ?? result[`EWREST_id_${i}`]
|
||||
recordIds.push(String(id))
|
||||
i++
|
||||
}
|
||||
if (recordIds.length === 0 && result.id !== undefined) {
|
||||
recordIds.push(String(result.id))
|
||||
}
|
||||
}
|
||||
|
||||
const totalCountRaw =
|
||||
result.EWREST_id_length ??
|
||||
result.totalCount ??
|
||||
result.total ??
|
||||
result.count ??
|
||||
data.EWREST_id_length ??
|
||||
data.totalCount ??
|
||||
data.total ??
|
||||
data.count ??
|
||||
recordIds.length
|
||||
|
||||
return {
|
||||
success: data.success !== false,
|
||||
output: {
|
||||
recordIds,
|
||||
totalCount: Number(totalCountRaw),
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error selecting Agiloft records:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftUpdateRecordContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
|
||||
import { buildUpdateRecordUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftUpdateRecordAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft update_record attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftUpdateRecordContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
let body: string
|
||||
try {
|
||||
body = JSON.stringify(JSON.parse(params.data))
|
||||
} catch {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: 'Invalid JSON in data parameter',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildUpdateRecordUrl(base, params),
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body,
|
||||
}),
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: `Agiloft error: ${response.status} - ${errorText}`,
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
const result = (data.result ?? data) as Record<string, unknown>
|
||||
const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null
|
||||
|
||||
return {
|
||||
success: data.success !== false,
|
||||
output: {
|
||||
id: id != null ? String(id) : null,
|
||||
fields: result ?? {},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating Agiloft record:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { airtableBasesSelectorContract } from '@/lib/api/contracts/selectors'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('AirtableBasesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const AIRTABLE_MAX_BASES_PAGES = 50
|
||||
|
||||
interface AirtableBase {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all Airtable bases, following the `offset` continuation token the Meta
|
||||
* API returns (an opaque string, passed back verbatim as `?offset=`) so the
|
||||
* full set is returned. Bounded by `AIRTABLE_MAX_BASES_PAGES`; logs a warning
|
||||
* rather than silently dropping bases when the cap is hit.
|
||||
*/
|
||||
async function fetchAllBases(accessToken: string): Promise<AirtableBase[]> {
|
||||
const bases: AirtableBase[] = []
|
||||
let offset: string | undefined
|
||||
|
||||
for (let page = 0; page < AIRTABLE_MAX_BASES_PAGES; page++) {
|
||||
const url = new URL('https://api.airtable.com/v0/meta/bases')
|
||||
if (offset) {
|
||||
url.searchParams.set('offset', offset)
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new AirtableFetchError(response.status, errorData)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { bases?: AirtableBase[]; offset?: string }
|
||||
if (Array.isArray(data.bases)) {
|
||||
bases.push(...data.bases)
|
||||
}
|
||||
|
||||
offset = data.offset || undefined
|
||||
if (!offset) {
|
||||
return bases
|
||||
}
|
||||
|
||||
if (page === AIRTABLE_MAX_BASES_PAGES - 1) {
|
||||
logger.warn('Airtable bases listing hit pagination cap; base list may be incomplete', {
|
||||
pages: AIRTABLE_MAX_BASES_PAGES,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return bases
|
||||
}
|
||||
|
||||
class AirtableFetchError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly details: unknown
|
||||
) {
|
||||
super('Failed to fetch Airtable bases')
|
||||
this.name = 'AirtableFetchError'
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const parsed = await parseRequest(airtableBasesSelectorContract, request, {})
|
||||
if (!parsed.success) {
|
||||
logger.error('Missing credential in request')
|
||||
return parsed.response
|
||||
}
|
||||
const { credential, workflowId } = parsed.data.body
|
||||
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const 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 }
|
||||
)
|
||||
}
|
||||
|
||||
let allBases: AirtableBase[]
|
||||
try {
|
||||
allBases = await fetchAllBases(accessToken)
|
||||
} catch (error) {
|
||||
if (error instanceof AirtableFetchError) {
|
||||
logger.error('Failed to fetch Airtable bases', {
|
||||
status: error.status,
|
||||
error: error.details,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Airtable bases', details: error.details },
|
||||
{ status: error.status }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const bases = allBases.map((base) => ({
|
||||
id: base.id,
|
||||
name: base.name,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ bases })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Airtable bases request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Airtable bases', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { airtableTablesSelectorContract } from '@/lib/api/contracts/selectors'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAirtableId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('AirtableTablesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const parsed = await parseRequest(airtableTablesSelectorContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { credential, workflowId, baseId } = parsed.data.body
|
||||
|
||||
const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId')
|
||||
if (!baseIdValidation.isValid) {
|
||||
logger.error('Invalid baseId', { error: baseIdValidation.error })
|
||||
return NextResponse.json({ error: baseIdValidation.error }, { 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 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 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.airtable.com/v0/meta/bases/${baseIdValidation.sanitized}/tables`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Airtable tables', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
baseId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Airtable tables', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const tables = (data.tables || []).map((table: { id: string; name: string }) => ({
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ tables })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Airtable tables request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Airtable tables', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigCreateApplicationContract } from '@/lib/api/contracts/tools/aws/appconfig-create-application'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, createApplication } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigCreateApplicationAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigCreateApplicationContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Creating AppConfig application ${params.name}`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await createApplication(client, params.name, params.description)
|
||||
logger.info(`[${requestId}] Created application ${result.id}`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to create application:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to create application: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigCreateConfigurationProfileContract } from '@/lib/api/contracts/tools/aws/appconfig-create-configuration-profile'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, createConfigurationProfile } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigCreateConfigurationProfileAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigCreateConfigurationProfileContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Creating AppConfig configuration profile ${params.name}`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await createConfigurationProfile(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.name,
|
||||
params.locationUri,
|
||||
params.description,
|
||||
params.retrievalRoleArn,
|
||||
params.type
|
||||
)
|
||||
logger.info(`[${requestId}] Created configuration profile ${result.id}`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to create configuration profile:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to create configuration profile: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigCreateEnvironmentContract } from '@/lib/api/contracts/tools/aws/appconfig-create-environment'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, createEnvironment } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigCreateEnvironmentAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigCreateEnvironmentContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Creating AppConfig environment ${params.name}`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await createEnvironment(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.name,
|
||||
params.description
|
||||
)
|
||||
logger.info(`[${requestId}] Created environment ${result.id}`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to create environment:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to create environment: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigCreateHostedConfigurationVersionContract } from '@/lib/api/contracts/tools/aws/appconfig-create-hosted-configuration-version'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, createHostedConfigurationVersion } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigCreateHostedConfigurationVersionAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(
|
||||
awsAppConfigCreateHostedConfigurationVersionContract,
|
||||
request,
|
||||
{ errorFormat: 'details', logger }
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Creating hosted configuration version for profile ${params.configurationProfileId}`
|
||||
)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await createHostedConfigurationVersion(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.configurationProfileId,
|
||||
params.content,
|
||||
params.contentType,
|
||||
params.description,
|
||||
params.latestVersionNumber,
|
||||
params.versionLabel
|
||||
)
|
||||
logger.info(`[${requestId}] Created hosted configuration version ${result.versionNumber}`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to create hosted configuration version:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to create hosted configuration version: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigDeleteApplicationContract } from '@/lib/api/contracts/tools/aws/appconfig-delete-application'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, deleteApplication } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigDeleteApplicationAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigDeleteApplicationContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Deleting AppConfig application ${params.applicationId}`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await deleteApplication(client, params.applicationId)
|
||||
logger.info(`[${requestId}] Deleted application`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to delete application:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to delete application: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigDeleteConfigurationProfileContract } from '@/lib/api/contracts/tools/aws/appconfig-delete-configuration-profile'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, deleteConfigurationProfile } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigDeleteConfigurationProfileAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigDeleteConfigurationProfileContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Deleting AppConfig configuration profile ${params.configurationProfileId}`
|
||||
)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await deleteConfigurationProfile(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.configurationProfileId
|
||||
)
|
||||
logger.info(`[${requestId}] Deleted configuration profile`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to delete configuration profile:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to delete configuration profile: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigDeleteEnvironmentContract } from '@/lib/api/contracts/tools/aws/appconfig-delete-environment'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, deleteEnvironment } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigDeleteEnvironmentAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigDeleteEnvironmentContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Deleting AppConfig environment ${params.environmentId}`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await deleteEnvironment(client, params.applicationId, params.environmentId)
|
||||
logger.info(`[${requestId}] Deleted environment`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to delete environment:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to delete environment: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigDeleteHostedConfigurationVersionContract } from '@/lib/api/contracts/tools/aws/appconfig-delete-hosted-configuration-version'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, deleteHostedConfigurationVersion } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigDeleteHostedConfigurationVersionAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(
|
||||
awsAppConfigDeleteHostedConfigurationVersionContract,
|
||||
request,
|
||||
{ errorFormat: 'details', logger }
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Deleting hosted configuration version ${params.versionNumber} for profile ${params.configurationProfileId}`
|
||||
)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await deleteHostedConfigurationVersion(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.configurationProfileId,
|
||||
params.versionNumber
|
||||
)
|
||||
logger.info(`[${requestId}] Deleted hosted configuration version`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to delete hosted configuration version:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to delete hosted configuration version: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigGetApplicationContract } from '@/lib/api/contracts/tools/aws/appconfig-get-application'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, getApplication } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigGetApplicationAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigGetApplicationContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Getting AppConfig application ${params.applicationId}`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getApplication(client, params.applicationId)
|
||||
logger.info(`[${requestId}] Retrieved application`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to get application:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to get application: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigGetConfigurationProfileContract } from '@/lib/api/contracts/tools/aws/appconfig-get-configuration-profile'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, getConfigurationProfile } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigGetConfigurationProfileAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigGetConfigurationProfileContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Getting AppConfig configuration profile ${params.configurationProfileId}`
|
||||
)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getConfigurationProfile(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.configurationProfileId
|
||||
)
|
||||
logger.info(`[${requestId}] Retrieved configuration profile`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to get configuration profile:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to get configuration profile: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigGetConfigurationContract } from '@/lib/api/contracts/tools/aws/appconfig-get-configuration'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigDataClient, getConfiguration } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigGetConfigurationAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigGetConfigurationContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Retrieving AppConfig configuration for ${params.applicationId}/${params.environmentId}/${params.configurationProfileId}`
|
||||
)
|
||||
|
||||
const client = createAppConfigDataClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getConfiguration(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.environmentId,
|
||||
params.configurationProfileId
|
||||
)
|
||||
logger.info(`[${requestId}] Retrieved configuration`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to retrieve configuration:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to retrieve configuration: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigGetDeploymentContract } from '@/lib/api/contracts/tools/aws/appconfig-get-deployment'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, getDeployment } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigGetDeploymentAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigGetDeploymentContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Getting AppConfig deployment ${params.deploymentNumber} in env ${params.environmentId}`
|
||||
)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getDeployment(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.environmentId,
|
||||
params.deploymentNumber
|
||||
)
|
||||
logger.info(`[${requestId}] Retrieved deployment`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to get deployment:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to get deployment: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigGetEnvironmentContract } from '@/lib/api/contracts/tools/aws/appconfig-get-environment'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, getEnvironment } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigGetEnvironmentAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigGetEnvironmentContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Getting AppConfig environment ${params.environmentId}`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getEnvironment(client, params.applicationId, params.environmentId)
|
||||
logger.info(`[${requestId}] Retrieved environment`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to get environment:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to get environment: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigGetHostedConfigurationVersionContract } from '@/lib/api/contracts/tools/aws/appconfig-get-hosted-configuration-version'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, getHostedConfigurationVersion } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigGetHostedConfigurationVersionAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(
|
||||
awsAppConfigGetHostedConfigurationVersionContract,
|
||||
request,
|
||||
{ errorFormat: 'details', logger }
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Getting hosted configuration version ${params.versionNumber} for profile ${params.configurationProfileId}`
|
||||
)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getHostedConfigurationVersion(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.configurationProfileId,
|
||||
params.versionNumber
|
||||
)
|
||||
logger.info(`[${requestId}] Retrieved hosted configuration version`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to get hosted configuration version:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to get hosted configuration version: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigListApplicationsContract } from '@/lib/api/contracts/tools/aws/appconfig-list-applications'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, listApplications } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigListApplicationsAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigListApplicationsContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Listing AppConfig applications`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await listApplications(client, params.maxResults, params.nextToken)
|
||||
logger.info(`[${requestId}] Listed ${result.count} applications`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to list applications:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to list applications: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigListConfigurationProfilesContract } from '@/lib/api/contracts/tools/aws/appconfig-list-configuration-profiles'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, listConfigurationProfiles } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigListConfigurationProfilesAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigListConfigurationProfilesContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Listing AppConfig configuration profiles for ${params.applicationId}`
|
||||
)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await listConfigurationProfiles(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.maxResults,
|
||||
params.nextToken
|
||||
)
|
||||
logger.info(`[${requestId}] Listed ${result.count} configuration profiles`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to list configuration profiles:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to list configuration profiles: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigListDeploymentStrategiesContract } from '@/lib/api/contracts/tools/aws/appconfig-list-deployment-strategies'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, listDeploymentStrategies } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigListDeploymentStrategiesAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigListDeploymentStrategiesContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Listing AppConfig deployment strategies`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await listDeploymentStrategies(client, params.maxResults, params.nextToken)
|
||||
logger.info(`[${requestId}] Listed ${result.count} deployment strategies`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to list deployment strategies:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to list deployment strategies: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigListDeploymentsContract } from '@/lib/api/contracts/tools/aws/appconfig-list-deployments'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, listDeployments } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigListDeploymentsAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigListDeploymentsContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Listing AppConfig deployments in env ${params.environmentId}`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await listDeployments(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.environmentId,
|
||||
params.maxResults,
|
||||
params.nextToken
|
||||
)
|
||||
logger.info(`[${requestId}] Listed ${result.count} deployments`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to list deployments:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to list deployments: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigListEnvironmentsContract } from '@/lib/api/contracts/tools/aws/appconfig-list-environments'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, listEnvironments } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigListEnvironmentsAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigListEnvironmentsContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Listing AppConfig environments for ${params.applicationId}`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await listEnvironments(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.maxResults,
|
||||
params.nextToken
|
||||
)
|
||||
logger.info(`[${requestId}] Listed ${result.count} environments`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to list environments:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to list environments: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigListHostedConfigurationVersionsContract } from '@/lib/api/contracts/tools/aws/appconfig-list-hosted-configuration-versions'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, listHostedConfigurationVersions } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigListHostedConfigurationVersionsAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(
|
||||
awsAppConfigListHostedConfigurationVersionsContract,
|
||||
request,
|
||||
{ errorFormat: 'details', logger }
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Listing AppConfig hosted configuration versions for profile ${params.configurationProfileId}`
|
||||
)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await listHostedConfigurationVersions(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.configurationProfileId,
|
||||
params.maxResults,
|
||||
params.nextToken
|
||||
)
|
||||
logger.info(`[${requestId}] Listed ${result.count} hosted configuration versions`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to list hosted configuration versions:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to list hosted configuration versions: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigStartDeploymentContract } from '@/lib/api/contracts/tools/aws/appconfig-start-deployment'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, startDeployment } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigStartDeploymentAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigStartDeploymentContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Starting AppConfig deployment in env ${params.environmentId}`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await startDeployment(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.environmentId,
|
||||
params.deploymentStrategyId,
|
||||
params.configurationProfileId,
|
||||
params.configurationVersion,
|
||||
params.description
|
||||
)
|
||||
logger.info(`[${requestId}] Started deployment ${result.deploymentNumber}`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to start deployment:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to start deployment: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigStopDeploymentContract } from '@/lib/api/contracts/tools/aws/appconfig-stop-deployment'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, stopDeployment } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigStopDeploymentAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigStopDeploymentContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Stopping AppConfig deployment ${params.deploymentNumber} in env ${params.environmentId}`
|
||||
)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await stopDeployment(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.environmentId,
|
||||
params.deploymentNumber
|
||||
)
|
||||
logger.info(`[${requestId}] Stopped deployment ${result.deploymentNumber}`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to stop deployment:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to stop deployment: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigUpdateApplicationContract } from '@/lib/api/contracts/tools/aws/appconfig-update-application'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, updateApplication } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigUpdateApplicationAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigUpdateApplicationContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Updating AppConfig application ${params.applicationId}`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await updateApplication(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.name,
|
||||
params.description
|
||||
)
|
||||
logger.info(`[${requestId}] Updated application`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to update application:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to update application: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigUpdateConfigurationProfileContract } from '@/lib/api/contracts/tools/aws/appconfig-update-configuration-profile'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, updateConfigurationProfile } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigUpdateConfigurationProfileAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigUpdateConfigurationProfileContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Updating AppConfig configuration profile ${params.configurationProfileId}`
|
||||
)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await updateConfigurationProfile(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.configurationProfileId,
|
||||
params.name,
|
||||
params.description,
|
||||
params.retrievalRoleArn
|
||||
)
|
||||
logger.info(`[${requestId}] Updated configuration profile`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to update configuration profile:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to update configuration profile: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAppConfigUpdateEnvironmentContract } from '@/lib/api/contracts/tools/aws/appconfig-update-environment'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAppConfigClient, updateEnvironment } from '../utils'
|
||||
|
||||
const logger = createLogger('AppConfigUpdateEnvironmentAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseToolRequest(awsAppConfigUpdateEnvironmentContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Updating AppConfig environment ${params.environmentId}`)
|
||||
|
||||
const client = createAppConfigClient({
|
||||
region: params.region,
|
||||
accessKeyId: params.accessKeyId,
|
||||
secretAccessKey: params.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await updateEnvironment(
|
||||
client,
|
||||
params.applicationId,
|
||||
params.environmentId,
|
||||
params.name,
|
||||
params.description
|
||||
)
|
||||
logger.info(`[${requestId}] Updated environment`)
|
||||
return NextResponse.json(result)
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] Failed to update environment:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to update environment: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,681 @@
|
||||
import {
|
||||
AppConfigClient,
|
||||
CreateApplicationCommand,
|
||||
CreateConfigurationProfileCommand,
|
||||
CreateEnvironmentCommand,
|
||||
CreateHostedConfigurationVersionCommand,
|
||||
DeleteApplicationCommand,
|
||||
DeleteConfigurationProfileCommand,
|
||||
DeleteEnvironmentCommand,
|
||||
DeleteHostedConfigurationVersionCommand,
|
||||
GetApplicationCommand,
|
||||
GetConfigurationProfileCommand,
|
||||
GetDeploymentCommand,
|
||||
GetEnvironmentCommand,
|
||||
GetHostedConfigurationVersionCommand,
|
||||
ListApplicationsCommand,
|
||||
ListConfigurationProfilesCommand,
|
||||
ListDeploymentStrategiesCommand,
|
||||
ListDeploymentsCommand,
|
||||
ListEnvironmentsCommand,
|
||||
ListHostedConfigurationVersionsCommand,
|
||||
StartDeploymentCommand,
|
||||
StopDeploymentCommand,
|
||||
UpdateApplicationCommand,
|
||||
UpdateConfigurationProfileCommand,
|
||||
UpdateEnvironmentCommand,
|
||||
} from '@aws-sdk/client-appconfig'
|
||||
import {
|
||||
AppConfigDataClient,
|
||||
GetLatestConfigurationCommand,
|
||||
StartConfigurationSessionCommand,
|
||||
} from '@aws-sdk/client-appconfigdata'
|
||||
import type { AppConfigConnectionConfig } from '@/tools/appconfig/types'
|
||||
|
||||
export function createAppConfigClient(config: AppConfigConnectionConfig): AppConfigClient {
|
||||
return new AppConfigClient({
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createAppConfigDataClient(config: AppConfigConnectionConfig): AppConfigDataClient {
|
||||
return new AppConfigDataClient({
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const textDecoder = new TextDecoder()
|
||||
|
||||
function decodeContent(content?: Uint8Array): string {
|
||||
if (!content || content.length === 0) return ''
|
||||
return textDecoder.decode(content)
|
||||
}
|
||||
|
||||
export async function listApplications(
|
||||
client: AppConfigClient,
|
||||
maxResults?: number | null,
|
||||
nextToken?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new ListApplicationsCommand({
|
||||
...(maxResults ? { MaxResults: maxResults } : {}),
|
||||
...(nextToken ? { NextToken: nextToken } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
const applications = (response.Items ?? []).map((item) => ({
|
||||
id: item.Id ?? '',
|
||||
name: item.Name ?? '',
|
||||
description: item.Description ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
applications,
|
||||
nextToken: response.NextToken ?? null,
|
||||
count: applications.length,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createApplication(
|
||||
client: AppConfigClient,
|
||||
name: string,
|
||||
description?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new CreateApplicationCommand({
|
||||
Name: name,
|
||||
...(description ? { Description: description } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
message: `Application "${response.Name ?? name}" created`,
|
||||
id: response.Id ?? '',
|
||||
name: response.Name ?? '',
|
||||
description: response.Description ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listEnvironments(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
maxResults?: number | null,
|
||||
nextToken?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new ListEnvironmentsCommand({
|
||||
ApplicationId: applicationId,
|
||||
...(maxResults ? { MaxResults: maxResults } : {}),
|
||||
...(nextToken ? { NextToken: nextToken } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
const environments = (response.Items ?? []).map((item) => ({
|
||||
applicationId: item.ApplicationId ?? '',
|
||||
id: item.Id ?? '',
|
||||
name: item.Name ?? '',
|
||||
description: item.Description ?? null,
|
||||
state: item.State ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
environments,
|
||||
nextToken: response.NextToken ?? null,
|
||||
count: environments.length,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEnvironment(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
name: string,
|
||||
description?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new CreateEnvironmentCommand({
|
||||
ApplicationId: applicationId,
|
||||
Name: name,
|
||||
...(description ? { Description: description } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
message: `Environment "${response.Name ?? name}" created`,
|
||||
applicationId: response.ApplicationId ?? applicationId,
|
||||
id: response.Id ?? '',
|
||||
name: response.Name ?? '',
|
||||
state: response.State ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listConfigurationProfiles(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
maxResults?: number | null,
|
||||
nextToken?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new ListConfigurationProfilesCommand({
|
||||
ApplicationId: applicationId,
|
||||
...(maxResults ? { MaxResults: maxResults } : {}),
|
||||
...(nextToken ? { NextToken: nextToken } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
const configurationProfiles = (response.Items ?? []).map((item) => ({
|
||||
applicationId: item.ApplicationId ?? '',
|
||||
id: item.Id ?? '',
|
||||
name: item.Name ?? '',
|
||||
description: null,
|
||||
locationUri: item.LocationUri ?? null,
|
||||
retrievalRoleArn: null,
|
||||
type: item.Type ?? null,
|
||||
validatorTypes: item.ValidatorTypes ?? [],
|
||||
}))
|
||||
|
||||
return {
|
||||
configurationProfiles,
|
||||
nextToken: response.NextToken ?? null,
|
||||
count: configurationProfiles.length,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createConfigurationProfile(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
name: string,
|
||||
locationUri: string,
|
||||
description?: string | null,
|
||||
retrievalRoleArn?: string | null,
|
||||
type?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new CreateConfigurationProfileCommand({
|
||||
ApplicationId: applicationId,
|
||||
Name: name,
|
||||
LocationUri: locationUri,
|
||||
...(description ? { Description: description } : {}),
|
||||
...(retrievalRoleArn ? { RetrievalRoleArn: retrievalRoleArn } : {}),
|
||||
...(type ? { Type: type } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
message: `Configuration profile "${response.Name ?? name}" created`,
|
||||
applicationId: response.ApplicationId ?? applicationId,
|
||||
id: response.Id ?? '',
|
||||
name: response.Name ?? '',
|
||||
locationUri: response.LocationUri ?? null,
|
||||
type: response.Type ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHostedConfigurationVersion(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
configurationProfileId: string,
|
||||
content: string,
|
||||
contentType: string,
|
||||
description?: string | null,
|
||||
latestVersionNumber?: number | null,
|
||||
versionLabel?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new CreateHostedConfigurationVersionCommand({
|
||||
ApplicationId: applicationId,
|
||||
ConfigurationProfileId: configurationProfileId,
|
||||
Content: new TextEncoder().encode(content),
|
||||
ContentType: contentType,
|
||||
...(description ? { Description: description } : {}),
|
||||
...(latestVersionNumber != null ? { LatestVersionNumber: latestVersionNumber } : {}),
|
||||
...(versionLabel ? { VersionLabel: versionLabel } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
message: `Hosted configuration version ${response.VersionNumber ?? ''} created`,
|
||||
applicationId: response.ApplicationId ?? applicationId,
|
||||
configurationProfileId: response.ConfigurationProfileId ?? configurationProfileId,
|
||||
versionNumber: response.VersionNumber ?? null,
|
||||
contentType: response.ContentType ?? null,
|
||||
versionLabel: response.VersionLabel ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHostedConfigurationVersion(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
configurationProfileId: string,
|
||||
versionNumber: number
|
||||
) {
|
||||
const response = await client.send(
|
||||
new GetHostedConfigurationVersionCommand({
|
||||
ApplicationId: applicationId,
|
||||
ConfigurationProfileId: configurationProfileId,
|
||||
VersionNumber: versionNumber,
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
applicationId: response.ApplicationId ?? applicationId,
|
||||
configurationProfileId: response.ConfigurationProfileId ?? configurationProfileId,
|
||||
versionNumber: response.VersionNumber ?? null,
|
||||
description: response.Description ?? null,
|
||||
content: decodeContent(response.Content),
|
||||
contentType: response.ContentType ?? null,
|
||||
versionLabel: response.VersionLabel ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listHostedConfigurationVersions(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
configurationProfileId: string,
|
||||
maxResults?: number | null,
|
||||
nextToken?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new ListHostedConfigurationVersionsCommand({
|
||||
ApplicationId: applicationId,
|
||||
ConfigurationProfileId: configurationProfileId,
|
||||
...(maxResults ? { MaxResults: maxResults } : {}),
|
||||
...(nextToken ? { NextToken: nextToken } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
const versions = (response.Items ?? []).map((item) => ({
|
||||
applicationId: item.ApplicationId ?? null,
|
||||
configurationProfileId: item.ConfigurationProfileId ?? null,
|
||||
versionNumber: item.VersionNumber ?? null,
|
||||
description: item.Description ?? null,
|
||||
contentType: item.ContentType ?? null,
|
||||
versionLabel: item.VersionLabel ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
versions,
|
||||
nextToken: response.NextToken ?? null,
|
||||
count: versions.length,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDeploymentStrategies(
|
||||
client: AppConfigClient,
|
||||
maxResults?: number | null,
|
||||
nextToken?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new ListDeploymentStrategiesCommand({
|
||||
...(maxResults ? { MaxResults: maxResults } : {}),
|
||||
...(nextToken ? { NextToken: nextToken } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
const deploymentStrategies = (response.Items ?? []).map((item) => ({
|
||||
id: item.Id ?? '',
|
||||
name: item.Name ?? '',
|
||||
description: item.Description ?? null,
|
||||
deploymentDurationInMinutes: item.DeploymentDurationInMinutes ?? null,
|
||||
growthType: item.GrowthType ?? null,
|
||||
growthFactor: item.GrowthFactor ?? null,
|
||||
finalBakeTimeInMinutes: item.FinalBakeTimeInMinutes ?? null,
|
||||
replicateTo: item.ReplicateTo ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
deploymentStrategies,
|
||||
nextToken: response.NextToken ?? null,
|
||||
count: deploymentStrategies.length,
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDeployment(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
environmentId: string,
|
||||
deploymentStrategyId: string,
|
||||
configurationProfileId: string,
|
||||
configurationVersion: string,
|
||||
description?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new StartDeploymentCommand({
|
||||
ApplicationId: applicationId,
|
||||
EnvironmentId: environmentId,
|
||||
DeploymentStrategyId: deploymentStrategyId,
|
||||
ConfigurationProfileId: configurationProfileId,
|
||||
ConfigurationVersion: configurationVersion,
|
||||
...(description ? { Description: description } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
message: `Deployment ${response.DeploymentNumber ?? ''} started`,
|
||||
deploymentNumber: response.DeploymentNumber ?? null,
|
||||
state: response.State ?? null,
|
||||
percentageComplete: response.PercentageComplete ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDeployment(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
environmentId: string,
|
||||
deploymentNumber: number
|
||||
) {
|
||||
const response = await client.send(
|
||||
new GetDeploymentCommand({
|
||||
ApplicationId: applicationId,
|
||||
EnvironmentId: environmentId,
|
||||
DeploymentNumber: deploymentNumber,
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
applicationId: response.ApplicationId ?? applicationId,
|
||||
environmentId: response.EnvironmentId ?? environmentId,
|
||||
deploymentStrategyId: response.DeploymentStrategyId ?? '',
|
||||
configurationProfileId: response.ConfigurationProfileId ?? '',
|
||||
deploymentNumber: response.DeploymentNumber ?? null,
|
||||
configurationName: response.ConfigurationName ?? null,
|
||||
configurationVersion: response.ConfigurationVersion ?? null,
|
||||
description: response.Description ?? null,
|
||||
state: response.State ?? null,
|
||||
percentageComplete: response.PercentageComplete ?? null,
|
||||
startedAt: response.StartedAt?.toISOString() ?? null,
|
||||
completedAt: response.CompletedAt?.toISOString() ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDeployments(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
environmentId: string,
|
||||
maxResults?: number | null,
|
||||
nextToken?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new ListDeploymentsCommand({
|
||||
ApplicationId: applicationId,
|
||||
EnvironmentId: environmentId,
|
||||
...(maxResults ? { MaxResults: maxResults } : {}),
|
||||
...(nextToken ? { NextToken: nextToken } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
const deployments = (response.Items ?? []).map((item) => ({
|
||||
deploymentNumber: item.DeploymentNumber ?? null,
|
||||
configurationName: item.ConfigurationName ?? null,
|
||||
configurationVersion: item.ConfigurationVersion ?? null,
|
||||
deploymentDurationInMinutes: item.DeploymentDurationInMinutes ?? null,
|
||||
growthType: item.GrowthType ?? null,
|
||||
growthFactor: item.GrowthFactor ?? null,
|
||||
finalBakeTimeInMinutes: item.FinalBakeTimeInMinutes ?? null,
|
||||
state: item.State ?? null,
|
||||
percentageComplete: item.PercentageComplete ?? null,
|
||||
startedAt: item.StartedAt?.toISOString() ?? null,
|
||||
completedAt: item.CompletedAt?.toISOString() ?? null,
|
||||
versionLabel: item.VersionLabel ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
deployments,
|
||||
nextToken: response.NextToken ?? null,
|
||||
count: deployments.length,
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopDeployment(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
environmentId: string,
|
||||
deploymentNumber: number
|
||||
) {
|
||||
const response = await client.send(
|
||||
new StopDeploymentCommand({
|
||||
ApplicationId: applicationId,
|
||||
EnvironmentId: environmentId,
|
||||
DeploymentNumber: deploymentNumber,
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
message: `Deployment ${response.DeploymentNumber ?? deploymentNumber} stopped`,
|
||||
deploymentNumber: response.DeploymentNumber ?? null,
|
||||
state: response.State ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfiguration(
|
||||
client: AppConfigDataClient,
|
||||
applicationId: string,
|
||||
environmentId: string,
|
||||
configurationProfileId: string
|
||||
) {
|
||||
const session = await client.send(
|
||||
new StartConfigurationSessionCommand({
|
||||
ApplicationIdentifier: applicationId,
|
||||
EnvironmentIdentifier: environmentId,
|
||||
ConfigurationProfileIdentifier: configurationProfileId,
|
||||
})
|
||||
)
|
||||
|
||||
const response = await client.send(
|
||||
new GetLatestConfigurationCommand({
|
||||
ConfigurationToken: session.InitialConfigurationToken,
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
configuration: decodeContent(response.Configuration),
|
||||
contentType: response.ContentType ?? null,
|
||||
versionLabel: response.VersionLabel ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApplication(client: AppConfigClient, applicationId: string) {
|
||||
const response = await client.send(new GetApplicationCommand({ ApplicationId: applicationId }))
|
||||
|
||||
return {
|
||||
id: response.Id ?? '',
|
||||
name: response.Name ?? '',
|
||||
description: response.Description ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateApplication(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
name?: string | null,
|
||||
description?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new UpdateApplicationCommand({
|
||||
ApplicationId: applicationId,
|
||||
...(name ? { Name: name } : {}),
|
||||
...(description != null ? { Description: description } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
message: `Application "${response.Name ?? applicationId}" updated`,
|
||||
id: response.Id ?? '',
|
||||
name: response.Name ?? '',
|
||||
description: response.Description ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteApplication(client: AppConfigClient, applicationId: string) {
|
||||
await client.send(new DeleteApplicationCommand({ ApplicationId: applicationId }))
|
||||
|
||||
return {
|
||||
message: `Application ${applicationId} deleted`,
|
||||
id: applicationId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEnvironment(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
environmentId: string
|
||||
) {
|
||||
const response = await client.send(
|
||||
new GetEnvironmentCommand({ ApplicationId: applicationId, EnvironmentId: environmentId })
|
||||
)
|
||||
|
||||
return {
|
||||
applicationId: response.ApplicationId ?? applicationId,
|
||||
id: response.Id ?? '',
|
||||
name: response.Name ?? '',
|
||||
description: response.Description ?? null,
|
||||
state: response.State ?? null,
|
||||
monitors: (response.Monitors ?? []).map((monitor) => ({
|
||||
alarmArn: monitor.AlarmArn ?? '',
|
||||
alarmRoleArn: monitor.AlarmRoleArn ?? null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEnvironment(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
environmentId: string,
|
||||
name?: string | null,
|
||||
description?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new UpdateEnvironmentCommand({
|
||||
ApplicationId: applicationId,
|
||||
EnvironmentId: environmentId,
|
||||
...(name ? { Name: name } : {}),
|
||||
...(description != null ? { Description: description } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
message: `Environment "${response.Name ?? environmentId}" updated`,
|
||||
applicationId: response.ApplicationId ?? applicationId,
|
||||
id: response.Id ?? '',
|
||||
name: response.Name ?? '',
|
||||
state: response.State ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEnvironment(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
environmentId: string
|
||||
) {
|
||||
await client.send(
|
||||
new DeleteEnvironmentCommand({ ApplicationId: applicationId, EnvironmentId: environmentId })
|
||||
)
|
||||
|
||||
return {
|
||||
message: `Environment ${environmentId} deleted`,
|
||||
applicationId,
|
||||
id: environmentId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfigurationProfile(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
configurationProfileId: string
|
||||
) {
|
||||
const response = await client.send(
|
||||
new GetConfigurationProfileCommand({
|
||||
ApplicationId: applicationId,
|
||||
ConfigurationProfileId: configurationProfileId,
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
applicationId: response.ApplicationId ?? applicationId,
|
||||
id: response.Id ?? '',
|
||||
name: response.Name ?? '',
|
||||
description: response.Description ?? null,
|
||||
locationUri: response.LocationUri ?? null,
|
||||
retrievalRoleArn: response.RetrievalRoleArn ?? null,
|
||||
type: response.Type ?? null,
|
||||
validators: (response.Validators ?? []).map((validator) => ({
|
||||
type: validator.Type ?? '',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConfigurationProfile(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
configurationProfileId: string,
|
||||
name?: string | null,
|
||||
description?: string | null,
|
||||
retrievalRoleArn?: string | null
|
||||
) {
|
||||
const response = await client.send(
|
||||
new UpdateConfigurationProfileCommand({
|
||||
ApplicationId: applicationId,
|
||||
ConfigurationProfileId: configurationProfileId,
|
||||
...(name ? { Name: name } : {}),
|
||||
...(description != null ? { Description: description } : {}),
|
||||
...(retrievalRoleArn != null ? { RetrievalRoleArn: retrievalRoleArn } : {}),
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
message: `Configuration profile "${response.Name ?? configurationProfileId}" updated`,
|
||||
applicationId: response.ApplicationId ?? applicationId,
|
||||
id: response.Id ?? '',
|
||||
name: response.Name ?? '',
|
||||
description: response.Description ?? null,
|
||||
type: response.Type ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteConfigurationProfile(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
configurationProfileId: string
|
||||
) {
|
||||
await client.send(
|
||||
new DeleteConfigurationProfileCommand({
|
||||
ApplicationId: applicationId,
|
||||
ConfigurationProfileId: configurationProfileId,
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
message: `Configuration profile ${configurationProfileId} deleted`,
|
||||
applicationId,
|
||||
id: configurationProfileId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHostedConfigurationVersion(
|
||||
client: AppConfigClient,
|
||||
applicationId: string,
|
||||
configurationProfileId: string,
|
||||
versionNumber: number
|
||||
) {
|
||||
await client.send(
|
||||
new DeleteHostedConfigurationVersionCommand({
|
||||
ApplicationId: applicationId,
|
||||
ConfigurationProfileId: configurationProfileId,
|
||||
VersionNumber: versionNumber,
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
message: `Hosted configuration version ${versionNumber} deleted`,
|
||||
applicationId,
|
||||
configurationProfileId,
|
||||
versionNumber,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaAddCommentContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaAddCommentAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaAddCommentContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, taskGid, text } = parsed.data.body
|
||||
|
||||
const taskGidValidation = validateAlphanumericId(taskGid, 'taskGid', 100)
|
||||
if (!taskGidValidation.isValid) {
|
||||
return NextResponse.json({ error: taskGidValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://app.asana.com/api/1.0/tasks/${taskGid}/stories`
|
||||
|
||||
const body = {
|
||||
data: {
|
||||
text,
|
||||
},
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
details: errorText,
|
||||
},
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const story = result.data
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
gid: story.gid,
|
||||
text: story.text || '',
|
||||
created_at: story.created_at,
|
||||
created_by: story.created_by
|
||||
? {
|
||||
gid: story.created_by.gid,
|
||||
name: story.created_by.name,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error processing request:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to add comment to Asana task',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaAddFollowersContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaAddFollowersAPI')
|
||||
|
||||
interface AsanaFollower {
|
||||
gid: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaAddFollowersContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, taskGid, followers } = parsed.data.body
|
||||
|
||||
const taskGidValidation = validateAlphanumericId(taskGid, 'taskGid', 100)
|
||||
if (!taskGidValidation.isValid) {
|
||||
return NextResponse.json({ error: taskGidValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
for (const follower of followers) {
|
||||
const followerValidation = validateAlphanumericId(follower, 'follower', 100)
|
||||
if (!followerValidation.isValid) {
|
||||
return NextResponse.json({ error: followerValidation.error }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const url = `https://app.asana.com/api/1.0/tasks/${taskGid}/addFollowers?opt_fields=name,followers.name`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ data: { followers } }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage, details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const task = result.data
|
||||
const taskFollowers: AsanaFollower[] = Array.isArray(task.followers) ? task.followers : []
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
gid: task.gid,
|
||||
name: task.name || '',
|
||||
followers: taskFollowers.map((follower) => ({
|
||||
gid: follower.gid,
|
||||
name: follower.name,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error adding followers to Asana task:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to add followers to Asana task', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaCreateProjectContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaCreateProjectAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaCreateProjectContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, workspace, name, notes } = parsed.data.body
|
||||
|
||||
const workspaceValidation = validateAlphanumericId(workspace, 'workspace', 100)
|
||||
if (!workspaceValidation.isValid) {
|
||||
return NextResponse.json({ error: workspaceValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const projectData: Record<string, unknown> = { name, workspace }
|
||||
if (notes) {
|
||||
projectData.notes = notes
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
'https://app.asana.com/api/1.0/projects?opt_fields=name,notes,archived,color,created_at,modified_at,permalink_url',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ data: projectData }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage, details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const project = result.data
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
gid: project.gid,
|
||||
name: project.name,
|
||||
notes: project.notes || '',
|
||||
archived: project.archived ?? false,
|
||||
color: project.color ?? null,
|
||||
created_at: project.created_at,
|
||||
modified_at: project.modified_at,
|
||||
permalink_url: project.permalink_url,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error creating Asana project:', {
|
||||
error: toError(error).message,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Internal server error'), success: false },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaCreateSectionContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaCreateSectionAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaCreateSectionContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, projectGid, name } = parsed.data.body
|
||||
|
||||
const projectGidValidation = validateAlphanumericId(projectGid, 'projectGid', 100)
|
||||
if (!projectGidValidation.isValid) {
|
||||
return NextResponse.json({ error: projectGidValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://app.asana.com/api/1.0/projects/${projectGid}/sections`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ data: { name } }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage, details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const section = result.data
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
gid: section.gid,
|
||||
name: section.name,
|
||||
created_at: section.created_at,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error creating Asana section:', {
|
||||
error: toError(error).message,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Internal server error'), success: false },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaCreateSubtaskContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaCreateSubtaskAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaCreateSubtaskContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, taskGid, name, notes, assignee, due_on } = parsed.data.body
|
||||
|
||||
const taskGidValidation = validateAlphanumericId(taskGid, 'taskGid', 100)
|
||||
if (!taskGidValidation.isValid) {
|
||||
return NextResponse.json({ error: taskGidValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const subtaskData: Record<string, unknown> = { name }
|
||||
if (notes) {
|
||||
subtaskData.notes = notes
|
||||
}
|
||||
if (assignee) {
|
||||
subtaskData.assignee = assignee
|
||||
}
|
||||
if (due_on) {
|
||||
subtaskData.due_on = due_on
|
||||
}
|
||||
|
||||
const url = `https://app.asana.com/api/1.0/tasks/${taskGid}/subtasks?opt_fields=name,notes,completed,created_at,permalink_url`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ data: subtaskData }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage, details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const task = result.data
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
gid: task.gid,
|
||||
name: task.name,
|
||||
notes: task.notes || '',
|
||||
completed: task.completed || false,
|
||||
created_at: task.created_at,
|
||||
permalink_url: task.permalink_url,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error creating Asana subtask:', {
|
||||
error: toError(error).message,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Internal server error'), success: false },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaCreateTaskContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaCreateTaskAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaCreateTaskContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, workspace, name, notes, assignee, due_on } = parsed.data.body
|
||||
|
||||
const workspaceValidation = validateAlphanumericId(workspace, 'workspace', 100)
|
||||
if (!workspaceValidation.isValid) {
|
||||
return NextResponse.json({ error: workspaceValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url =
|
||||
'https://app.asana.com/api/1.0/tasks?opt_fields=name,notes,completed,created_at,permalink_url'
|
||||
|
||||
const taskData: Record<string, unknown> = {
|
||||
name,
|
||||
workspace,
|
||||
}
|
||||
|
||||
if (notes) {
|
||||
taskData.notes = notes
|
||||
}
|
||||
|
||||
if (assignee) {
|
||||
taskData.assignee = assignee
|
||||
}
|
||||
|
||||
if (due_on) {
|
||||
taskData.due_on = due_on
|
||||
}
|
||||
|
||||
const body = { data: taskData }
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
details: errorText,
|
||||
},
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const task = result.data
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
gid: task.gid,
|
||||
name: task.name,
|
||||
notes: task.notes || '',
|
||||
completed: task.completed || false,
|
||||
created_at: task.created_at,
|
||||
permalink_url: task.permalink_url,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error creating Asana task:', {
|
||||
error: toError(error).message,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getErrorMessage(error, 'Internal server error'),
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaDeleteTaskContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaDeleteTaskAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaDeleteTaskContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, taskGid } = parsed.data.body
|
||||
|
||||
const taskGidValidation = validateAlphanumericId(taskGid, 'taskGid', 100)
|
||||
if (!taskGidValidation.isValid) {
|
||||
return NextResponse.json({ error: taskGidValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://app.asana.com/api/1.0/tasks/${taskGid}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage, details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
gid: taskGid,
|
||||
deleted: true,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error deleting Asana task:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete Asana task', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaGetProjectContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaGetProjectAPI')
|
||||
|
||||
const PROJECT_OPT_FIELDS = 'name,notes,archived,color,created_at,modified_at,permalink_url'
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaGetProjectContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, projectGid } = parsed.data.body
|
||||
|
||||
const projectGidValidation = validateAlphanumericId(projectGid, 'projectGid', 100)
|
||||
if (!projectGidValidation.isValid) {
|
||||
return NextResponse.json({ error: projectGidValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://app.asana.com/api/1.0/projects/${projectGid}?opt_fields=${PROJECT_OPT_FIELDS}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage, details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const project = result.data
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
gid: project.gid,
|
||||
name: project.name,
|
||||
notes: project.notes || '',
|
||||
archived: project.archived ?? false,
|
||||
color: project.color ?? null,
|
||||
created_at: project.created_at,
|
||||
modified_at: project.modified_at,
|
||||
permalink_url: project.permalink_url,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error processing request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Asana project', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaGetProjectsContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaGetProjectsAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaGetProjectsContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, workspace } = parsed.data.body
|
||||
|
||||
const workspaceValidation = validateAlphanumericId(workspace, 'workspace', 100)
|
||||
if (!workspaceValidation.isValid) {
|
||||
return NextResponse.json({ error: workspaceValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://app.asana.com/api/1.0/projects?workspace=${workspace}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
details: errorText,
|
||||
},
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const projects = result.data
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
projects: projects.map((project: { gid: string; name: string; resource_type: string }) => ({
|
||||
gid: project.gid,
|
||||
name: project.name,
|
||||
resource_type: project.resource_type,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error processing request:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to retrieve Asana projects',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaGetTaskContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaGetTaskAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaGetTaskContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, taskGid, workspace, project, limit } = parsed.data.body
|
||||
|
||||
if (taskGid) {
|
||||
const taskGidValidation = validateAlphanumericId(taskGid, 'taskGid', 100)
|
||||
if (!taskGidValidation.isValid) {
|
||||
return NextResponse.json({ error: taskGidValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://app.asana.com/api/1.0/tasks/${taskGid}?opt_fields=gid,name,notes,completed,assignee,assignee.name,due_on,created_at,modified_at,created_by,created_by.name,resource_type,resource_subtype`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
details: errorText,
|
||||
},
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const task = result.data
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
gid: task.gid,
|
||||
resource_type: task.resource_type,
|
||||
resource_subtype: task.resource_subtype,
|
||||
name: task.name,
|
||||
notes: task.notes || '',
|
||||
completed: task.completed || false,
|
||||
assignee: task.assignee
|
||||
? {
|
||||
gid: task.assignee.gid,
|
||||
name: task.assignee.name,
|
||||
}
|
||||
: undefined,
|
||||
created_by: task.created_by
|
||||
? {
|
||||
gid: task.created_by.gid,
|
||||
resource_type: task.created_by.resource_type,
|
||||
name: task.created_by.name,
|
||||
}
|
||||
: undefined,
|
||||
due_on: task.due_on || undefined,
|
||||
created_at: task.created_at,
|
||||
modified_at: task.modified_at,
|
||||
})
|
||||
}
|
||||
|
||||
if (!workspace && !project) {
|
||||
logger.error('Either taskGid or workspace/project must be provided')
|
||||
return NextResponse.json(
|
||||
{ error: 'Either taskGid or workspace/project must be provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (project) {
|
||||
const projectValidation = validateAlphanumericId(project, 'project', 100)
|
||||
if (!projectValidation.isValid) {
|
||||
return NextResponse.json({ error: projectValidation.error }, { status: 400 })
|
||||
}
|
||||
params.append('project', project)
|
||||
} else if (workspace) {
|
||||
const workspaceValidation = validateAlphanumericId(workspace, 'workspace', 100)
|
||||
if (!workspaceValidation.isValid) {
|
||||
return NextResponse.json({ error: workspaceValidation.error }, { status: 400 })
|
||||
}
|
||||
params.append('workspace', workspace)
|
||||
}
|
||||
|
||||
if (limit) {
|
||||
params.append('limit', String(limit))
|
||||
} else {
|
||||
params.append('limit', '50')
|
||||
}
|
||||
|
||||
params.append(
|
||||
'opt_fields',
|
||||
'gid,name,notes,completed,assignee,assignee.name,due_on,created_at,modified_at,created_by,created_by.name,resource_type,resource_subtype'
|
||||
)
|
||||
|
||||
const url = `https://app.asana.com/api/1.0/tasks?${params.toString()}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
details: errorText,
|
||||
},
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const tasks = result.data
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
tasks: tasks.map((task: any) => ({
|
||||
gid: task.gid,
|
||||
resource_type: task.resource_type,
|
||||
resource_subtype: task.resource_subtype,
|
||||
name: task.name,
|
||||
notes: task.notes || '',
|
||||
completed: task.completed || false,
|
||||
assignee: task.assignee
|
||||
? {
|
||||
gid: task.assignee.gid,
|
||||
name: task.assignee.name,
|
||||
}
|
||||
: undefined,
|
||||
created_by: task.created_by
|
||||
? {
|
||||
gid: task.created_by.gid,
|
||||
resource_type: task.created_by.resource_type,
|
||||
name: task.created_by.name,
|
||||
}
|
||||
: undefined,
|
||||
due_on: task.due_on || undefined,
|
||||
created_at: task.created_at,
|
||||
modified_at: task.modified_at,
|
||||
})),
|
||||
next_page: result.next_page,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error processing request:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to retrieve Asana task(s)',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaListSectionsContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaListSectionsAPI')
|
||||
|
||||
interface AsanaSection {
|
||||
gid: string
|
||||
name: string
|
||||
resource_type?: string
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaListSectionsContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, projectGid } = parsed.data.body
|
||||
|
||||
const projectGidValidation = validateAlphanumericId(projectGid, 'projectGid', 100)
|
||||
if (!projectGidValidation.isValid) {
|
||||
return NextResponse.json({ error: projectGidValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://app.asana.com/api/1.0/projects/${projectGid}/sections`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage, details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const sections: AsanaSection[] = Array.isArray(result.data) ? result.data : []
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
sections: sections.map((section) => ({
|
||||
gid: section.gid,
|
||||
name: section.name,
|
||||
resource_type: section.resource_type,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error processing request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Asana sections', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaListWorkspacesContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaListWorkspacesAPI')
|
||||
|
||||
interface AsanaWorkspace {
|
||||
gid: string
|
||||
name: string
|
||||
resource_type?: string
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaListWorkspacesContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken } = parsed.data.body
|
||||
|
||||
const url = 'https://app.asana.com/api/1.0/workspaces?limit=100'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage, details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const workspaces: AsanaWorkspace[] = Array.isArray(result.data) ? result.data : []
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
workspaces: workspaces.map((workspace) => ({
|
||||
gid: workspace.gid,
|
||||
name: workspace.name,
|
||||
resource_type: workspace.resource_type,
|
||||
})),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error processing request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Asana workspaces', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaSearchTasksContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaSearchTasksAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaSearchTasksContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, workspace, text, assignee, projects, completed } = parsed.data.body
|
||||
|
||||
const workspaceValidation = validateAlphanumericId(workspace, 'workspace', 100)
|
||||
if (!workspaceValidation.isValid) {
|
||||
return NextResponse.json({ error: workspaceValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (text) {
|
||||
params.append('text', text)
|
||||
}
|
||||
|
||||
if (assignee) {
|
||||
params.append('assignee.any', assignee)
|
||||
}
|
||||
|
||||
if (projects && Array.isArray(projects) && projects.length > 0) {
|
||||
params.append('projects.any', projects.join(','))
|
||||
}
|
||||
|
||||
if (completed !== undefined) {
|
||||
params.append('completed', String(completed))
|
||||
}
|
||||
|
||||
params.append(
|
||||
'opt_fields',
|
||||
'gid,name,notes,completed,assignee,assignee.name,due_on,created_at,modified_at,created_by,created_by.name,resource_type,resource_subtype'
|
||||
)
|
||||
|
||||
const url = `https://app.asana.com/api/1.0/workspaces/${workspace}/tasks/search?${params.toString()}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
details: errorText,
|
||||
},
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const tasks = result.data
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
tasks: tasks.map((task: any) => ({
|
||||
gid: task.gid,
|
||||
resource_type: task.resource_type,
|
||||
resource_subtype: task.resource_subtype,
|
||||
name: task.name,
|
||||
notes: task.notes || '',
|
||||
completed: task.completed || false,
|
||||
assignee: task.assignee
|
||||
? {
|
||||
gid: task.assignee.gid,
|
||||
name: task.assignee.name,
|
||||
}
|
||||
: undefined,
|
||||
created_by: task.created_by
|
||||
? {
|
||||
gid: task.created_by.gid,
|
||||
resource_type: task.created_by.resource_type,
|
||||
name: task.created_by.name,
|
||||
}
|
||||
: undefined,
|
||||
due_on: task.due_on || undefined,
|
||||
created_at: task.created_at,
|
||||
modified_at: task.modified_at,
|
||||
})),
|
||||
next_page: result.next_page,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error processing request:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to search Asana tasks',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaUpdateTaskContract } from '@/lib/api/contracts/tools/asana'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AsanaUpdateTaskAPI')
|
||||
|
||||
export const PUT = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(asanaUpdateTaskContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, taskGid, name, notes, assignee, completed, due_on } = parsed.data.body
|
||||
|
||||
const taskGidValidation = validateAlphanumericId(taskGid, 'taskGid', 100)
|
||||
if (!taskGidValidation.isValid) {
|
||||
return NextResponse.json({ error: taskGidValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://app.asana.com/api/1.0/tasks/${taskGid}`
|
||||
|
||||
const taskData: Record<string, unknown> = {}
|
||||
|
||||
if (name !== undefined) {
|
||||
taskData.name = name
|
||||
}
|
||||
|
||||
if (notes !== undefined) {
|
||||
taskData.notes = notes
|
||||
}
|
||||
|
||||
if (assignee !== undefined) {
|
||||
taskData.assignee = assignee
|
||||
}
|
||||
|
||||
if (completed !== undefined) {
|
||||
taskData.completed = completed
|
||||
}
|
||||
|
||||
if (due_on !== undefined) {
|
||||
taskData.due_on = due_on
|
||||
}
|
||||
|
||||
const body = { data: taskData }
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Asana API error: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText)
|
||||
const asanaError = errorData.errors?.[0]
|
||||
if (asanaError) {
|
||||
errorMessage = `${asanaError.message || errorMessage} (${asanaError.help || ''})`
|
||||
}
|
||||
logger.error('Asana API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
} catch (_e) {
|
||||
logger.error('Asana API error (unparsed):', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
details: errorText,
|
||||
},
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
const task = result.data
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ts: new Date().toISOString(),
|
||||
gid: task.gid,
|
||||
name: task.name,
|
||||
notes: task.notes || '',
|
||||
completed: task.completed || false,
|
||||
modified_at: task.modified_at,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error updating Asana task:', {
|
||||
error: toError(error).message,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: getErrorMessage(error, 'Internal server error'),
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { asanaWorkspacesSelectorContract } from '@/lib/api/contracts/selectors'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('AsanaWorkspacesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const ASANA_PAGE_LIMIT = 100
|
||||
const ASANA_MAX_WORKSPACES_PAGES = 50
|
||||
|
||||
interface AsanaWorkspace {
|
||||
gid: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface AsanaWorkspacesPage {
|
||||
data?: AsanaWorkspace[]
|
||||
next_page?: {
|
||||
offset?: string
|
||||
} | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all Asana workspaces using `limit`/`offset` pagination, following
|
||||
* `next_page.offset` (an opaque token, passed back verbatim as `?offset=`)
|
||||
* until `next_page` is null so the full set is returned. Bounded by
|
||||
* `ASANA_MAX_WORKSPACES_PAGES`; logs a warning rather than silently dropping
|
||||
* workspaces when the cap is hit.
|
||||
*/
|
||||
async function fetchAllWorkspaces(accessToken: string): Promise<AsanaWorkspace[]> {
|
||||
const workspaces: AsanaWorkspace[] = []
|
||||
let offset: string | undefined
|
||||
|
||||
for (let page = 0; page < ASANA_MAX_WORKSPACES_PAGES; page++) {
|
||||
const url = new URL('https://app.asana.com/api/1.0/workspaces')
|
||||
url.searchParams.set('limit', String(ASANA_PAGE_LIMIT))
|
||||
if (offset) {
|
||||
url.searchParams.set('offset', offset)
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new AsanaFetchError(response.status, errorData)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AsanaWorkspacesPage
|
||||
if (Array.isArray(data.data)) {
|
||||
workspaces.push(...data.data)
|
||||
}
|
||||
|
||||
offset = data.next_page?.offset || undefined
|
||||
if (!offset) {
|
||||
return workspaces
|
||||
}
|
||||
|
||||
if (page === ASANA_MAX_WORKSPACES_PAGES - 1) {
|
||||
logger.warn('Asana workspaces listing hit pagination cap; workspace list may be incomplete', {
|
||||
pages: ASANA_MAX_WORKSPACES_PAGES,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return workspaces
|
||||
}
|
||||
|
||||
class AsanaFetchError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly details: unknown
|
||||
) {
|
||||
super('Failed to fetch Asana workspaces')
|
||||
this.name = 'AsanaFetchError'
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const parsed = await parseRequest(asanaWorkspacesSelectorContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { credential, workflowId } = parsed.data.body
|
||||
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const 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 }
|
||||
)
|
||||
}
|
||||
|
||||
let allWorkspaces: AsanaWorkspace[]
|
||||
try {
|
||||
allWorkspaces = await fetchAllWorkspaces(accessToken)
|
||||
} catch (error) {
|
||||
if (error instanceof AsanaFetchError) {
|
||||
logger.error('Failed to fetch Asana workspaces', {
|
||||
status: error.status,
|
||||
error: error.details,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Asana workspaces', details: error.details },
|
||||
{ status: error.status }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const workspaces = allWorkspaces.map((workspace) => ({
|
||||
id: workspace.gid,
|
||||
name: workspace.name,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ workspaces })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Asana workspaces request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Asana workspaces', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { BatchGetQueryExecutionCommand } from '@aws-sdk/client-athena'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAthenaBatchGetQueryExecutionContract } from '@/lib/api/contracts/tools/aws/athena-batch-get-query-execution'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAthenaClient } from '@/app/api/tools/athena/utils'
|
||||
|
||||
const logger = createLogger('AthenaBatchGetQueryExecution')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsAthenaBatchGetQueryExecutionContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
const client = createAthenaClient({
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const command = new BatchGetQueryExecutionCommand({
|
||||
QueryExecutionIds: data.queryExecutionIds,
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
queryExecutions: (response.QueryExecutions ?? []).map((execution) => ({
|
||||
queryExecutionId: execution.QueryExecutionId ?? '',
|
||||
query: execution.Query ?? null,
|
||||
state: execution.Status?.State ?? null,
|
||||
stateChangeReason: execution.Status?.StateChangeReason ?? null,
|
||||
statementType: execution.StatementType ?? null,
|
||||
database: execution.QueryExecutionContext?.Database ?? null,
|
||||
catalog: execution.QueryExecutionContext?.Catalog ?? null,
|
||||
workGroup: execution.WorkGroup ?? null,
|
||||
submissionDateTime: execution.Status?.SubmissionDateTime?.getTime() ?? null,
|
||||
completionDateTime: execution.Status?.CompletionDateTime?.getTime() ?? null,
|
||||
dataScannedInBytes: execution.Statistics?.DataScannedInBytes ?? null,
|
||||
engineExecutionTimeInMillis: execution.Statistics?.EngineExecutionTimeInMillis ?? null,
|
||||
queryPlanningTimeInMillis: execution.Statistics?.QueryPlanningTimeInMillis ?? null,
|
||||
queryQueueTimeInMillis: execution.Statistics?.QueryQueueTimeInMillis ?? null,
|
||||
totalExecutionTimeInMillis: execution.Statistics?.TotalExecutionTimeInMillis ?? null,
|
||||
outputLocation: execution.ResultConfiguration?.OutputLocation ?? null,
|
||||
})),
|
||||
unprocessedQueryExecutionIds: (response.UnprocessedQueryExecutionIds ?? []).map(
|
||||
(item) => ({
|
||||
queryExecutionId: item.QueryExecutionId ?? null,
|
||||
errorCode: item.ErrorCode ?? null,
|
||||
errorMessage: item.ErrorMessage ?? null,
|
||||
})
|
||||
),
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Failed to batch get Athena query executions')
|
||||
logger.error('BatchGetQueryExecution failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { CreateNamedQueryCommand } from '@aws-sdk/client-athena'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAthenaCreateNamedQueryContract } from '@/lib/api/contracts/tools/aws/athena-create-named-query'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAthenaClient } from '@/app/api/tools/athena/utils'
|
||||
|
||||
const logger = createLogger('AthenaCreateNamedQuery')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsAthenaCreateNamedQueryContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
const client = createAthenaClient({
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
})
|
||||
|
||||
const command = new CreateNamedQueryCommand({
|
||||
Name: data.name,
|
||||
Database: data.database,
|
||||
QueryString: data.queryString,
|
||||
...(data.description && { Description: data.description }),
|
||||
...(data.workGroup && { WorkGroup: data.workGroup }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
if (!response.NamedQueryId) {
|
||||
throw new Error('No named query ID returned')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
namedQueryId: response.NamedQueryId,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Failed to create Athena named query')
|
||||
logger.error('CreateNamedQuery failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { DeleteNamedQueryCommand } from '@aws-sdk/client-athena'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAthenaDeleteNamedQueryContract } from '@/lib/api/contracts/tools/aws/athena-delete-named-query'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAthenaClient } from '@/app/api/tools/athena/utils'
|
||||
|
||||
const logger = createLogger('AthenaDeleteNamedQuery')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsAthenaDeleteNamedQueryContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
const client = createAthenaClient({
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const command = new DeleteNamedQueryCommand({
|
||||
NamedQueryId: data.namedQueryId,
|
||||
})
|
||||
|
||||
await client.send(command)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Failed to delete Athena named query')
|
||||
logger.error('DeleteNamedQuery failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { GetNamedQueryCommand } from '@aws-sdk/client-athena'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAthenaGetNamedQueryContract } from '@/lib/api/contracts/tools/aws/athena-get-named-query'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAthenaClient } from '@/app/api/tools/athena/utils'
|
||||
|
||||
const logger = createLogger('AthenaGetNamedQuery')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsAthenaGetNamedQueryContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
const client = createAthenaClient({
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
})
|
||||
|
||||
const command = new GetNamedQueryCommand({
|
||||
NamedQueryId: data.namedQueryId,
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
const namedQuery = response.NamedQuery
|
||||
|
||||
if (!namedQuery) {
|
||||
throw new Error('No named query data returned')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
namedQueryId: namedQuery.NamedQueryId ?? data.namedQueryId,
|
||||
name: namedQuery.Name ?? '',
|
||||
description: namedQuery.Description ?? null,
|
||||
database: namedQuery.Database ?? '',
|
||||
queryString: namedQuery.QueryString ?? '',
|
||||
workGroup: namedQuery.WorkGroup ?? null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Failed to get Athena named query')
|
||||
logger.error('GetNamedQuery failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { GetQueryExecutionCommand } from '@aws-sdk/client-athena'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAthenaGetQueryExecutionContract } from '@/lib/api/contracts/tools/aws/athena-get-query-execution'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAthenaClient } from '@/app/api/tools/athena/utils'
|
||||
|
||||
const logger = createLogger('AthenaGetQueryExecution')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsAthenaGetQueryExecutionContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
const client = createAthenaClient({
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
})
|
||||
|
||||
const command = new GetQueryExecutionCommand({
|
||||
QueryExecutionId: data.queryExecutionId,
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
const execution = response.QueryExecution
|
||||
|
||||
if (!execution) {
|
||||
throw new Error('No query execution data returned')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
queryExecutionId: execution.QueryExecutionId ?? data.queryExecutionId,
|
||||
query: execution.Query ?? '',
|
||||
state: execution.Status?.State ?? 'UNKNOWN',
|
||||
stateChangeReason: execution.Status?.StateChangeReason ?? null,
|
||||
statementType: execution.StatementType ?? null,
|
||||
database: execution.QueryExecutionContext?.Database ?? null,
|
||||
catalog: execution.QueryExecutionContext?.Catalog ?? null,
|
||||
workGroup: execution.WorkGroup ?? null,
|
||||
submissionDateTime: execution.Status?.SubmissionDateTime?.getTime() ?? null,
|
||||
completionDateTime: execution.Status?.CompletionDateTime?.getTime() ?? null,
|
||||
dataScannedInBytes: execution.Statistics?.DataScannedInBytes ?? null,
|
||||
engineExecutionTimeInMillis: execution.Statistics?.EngineExecutionTimeInMillis ?? null,
|
||||
queryPlanningTimeInMillis: execution.Statistics?.QueryPlanningTimeInMillis ?? null,
|
||||
queryQueueTimeInMillis: execution.Statistics?.QueryQueueTimeInMillis ?? null,
|
||||
totalExecutionTimeInMillis: execution.Statistics?.TotalExecutionTimeInMillis ?? null,
|
||||
outputLocation: execution.ResultConfiguration?.OutputLocation ?? null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Failed to get Athena query execution')
|
||||
logger.error('GetQueryExecution failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { GetQueryResultsCommand } from '@aws-sdk/client-athena'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAthenaGetQueryResultsContract } from '@/lib/api/contracts/tools/aws/athena-get-query-results'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAthenaClient } from '@/app/api/tools/athena/utils'
|
||||
|
||||
const logger = createLogger('AthenaGetQueryResults')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsAthenaGetQueryResultsContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
const client = createAthenaClient({
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
})
|
||||
|
||||
const isFirstPage = !data.nextToken
|
||||
const adjustedMaxResults =
|
||||
data.maxResults !== undefined && isFirstPage ? data.maxResults + 1 : data.maxResults
|
||||
|
||||
const command = new GetQueryResultsCommand({
|
||||
QueryExecutionId: data.queryExecutionId,
|
||||
...(adjustedMaxResults !== undefined && { MaxResults: adjustedMaxResults }),
|
||||
...(data.nextToken && { NextToken: data.nextToken }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const columnInfo = response.ResultSet?.ResultSetMetadata?.ColumnInfo ?? []
|
||||
const columns = columnInfo.map((col) => ({
|
||||
name: col.Name ?? '',
|
||||
type: col.Type ?? 'varchar',
|
||||
}))
|
||||
|
||||
const rawRows = response.ResultSet?.Rows ?? []
|
||||
const dataRows = data.nextToken ? rawRows : rawRows.slice(1)
|
||||
const rows = dataRows.map((row) => {
|
||||
const record: Record<string, string> = {}
|
||||
const rowData = row.Data ?? []
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
record[columns[i].name] = rowData[i]?.VarCharValue ?? ''
|
||||
}
|
||||
return record
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
columns,
|
||||
rows,
|
||||
nextToken: response.NextToken ?? null,
|
||||
updateCount: response.UpdateCount ?? null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Failed to get Athena query results')
|
||||
logger.error('GetQueryResults failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ListDatabasesCommand } from '@aws-sdk/client-athena'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAthenaListDatabasesContract } from '@/lib/api/contracts/tools/aws/athena-list-databases'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAthenaClient } from '@/app/api/tools/athena/utils'
|
||||
|
||||
const logger = createLogger('AthenaListDatabases')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsAthenaListDatabasesContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
const client = createAthenaClient({
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const command = new ListDatabasesCommand({
|
||||
CatalogName: data.catalogName,
|
||||
...(data.workGroup && { WorkGroup: data.workGroup }),
|
||||
...(data.maxResults !== undefined && { MaxResults: data.maxResults }),
|
||||
...(data.nextToken && { NextToken: data.nextToken }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
databases: (response.DatabaseList ?? []).map((db) => ({
|
||||
name: db.Name ?? '',
|
||||
description: db.Description ?? null,
|
||||
})),
|
||||
nextToken: response.NextToken ?? null,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Failed to list Athena databases')
|
||||
logger.error('ListDatabases failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ListNamedQueriesCommand } from '@aws-sdk/client-athena'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAthenaListNamedQueriesContract } from '@/lib/api/contracts/tools/aws/athena-list-named-queries'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAthenaClient } from '@/app/api/tools/athena/utils'
|
||||
|
||||
const logger = createLogger('AthenaListNamedQueries')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsAthenaListNamedQueriesContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
const client = createAthenaClient({
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
})
|
||||
|
||||
const command = new ListNamedQueriesCommand({
|
||||
...(data.workGroup && { WorkGroup: data.workGroup }),
|
||||
...(data.maxResults !== undefined && { MaxResults: data.maxResults }),
|
||||
...(data.nextToken && { NextToken: data.nextToken }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
namedQueryIds: response.NamedQueryIds ?? [],
|
||||
nextToken: response.NextToken ?? null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Failed to list Athena named queries')
|
||||
logger.error('ListNamedQueries failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ListQueryExecutionsCommand } from '@aws-sdk/client-athena'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAthenaListQueryExecutionsContract } from '@/lib/api/contracts/tools/aws/athena-list-query-executions'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAthenaClient } from '@/app/api/tools/athena/utils'
|
||||
|
||||
const logger = createLogger('AthenaListQueryExecutions')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsAthenaListQueryExecutionsContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
const client = createAthenaClient({
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
})
|
||||
|
||||
const command = new ListQueryExecutionsCommand({
|
||||
...(data.workGroup && { WorkGroup: data.workGroup }),
|
||||
...(data.maxResults !== undefined && { MaxResults: data.maxResults }),
|
||||
...(data.nextToken && { NextToken: data.nextToken }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
queryExecutionIds: response.QueryExecutionIds ?? [],
|
||||
nextToken: response.NextToken ?? null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Failed to list Athena query executions')
|
||||
logger.error('ListQueryExecutions failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ListTableMetadataCommand } from '@aws-sdk/client-athena'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAthenaListTableMetadataContract } from '@/lib/api/contracts/tools/aws/athena-list-table-metadata'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAthenaClient } from '@/app/api/tools/athena/utils'
|
||||
|
||||
const logger = createLogger('AthenaListTableMetadata')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsAthenaListTableMetadataContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
const client = createAthenaClient({
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const command = new ListTableMetadataCommand({
|
||||
CatalogName: data.catalogName,
|
||||
DatabaseName: data.databaseName,
|
||||
...(data.expression && { Expression: data.expression }),
|
||||
...(data.workGroup && { WorkGroup: data.workGroup }),
|
||||
...(data.maxResults !== undefined && { MaxResults: data.maxResults }),
|
||||
...(data.nextToken && { NextToken: data.nextToken }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
tables: (response.TableMetadataList ?? []).map((table) => ({
|
||||
name: table.Name ?? '',
|
||||
tableType: table.TableType ?? null,
|
||||
createTime: table.CreateTime?.getTime() ?? null,
|
||||
lastAccessTime: table.LastAccessTime?.getTime() ?? null,
|
||||
columns: (table.Columns ?? []).map((col) => ({
|
||||
name: col.Name ?? '',
|
||||
type: col.Type ?? null,
|
||||
comment: col.Comment ?? null,
|
||||
})),
|
||||
partitionKeys: (table.PartitionKeys ?? []).map((col) => ({
|
||||
name: col.Name ?? '',
|
||||
type: col.Type ?? null,
|
||||
comment: col.Comment ?? null,
|
||||
})),
|
||||
})),
|
||||
nextToken: response.NextToken ?? null,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Failed to list Athena table metadata')
|
||||
logger.error('ListTableMetadata failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { StartQueryExecutionCommand } from '@aws-sdk/client-athena'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAthenaStartQueryContract } from '@/lib/api/contracts/tools/aws/athena-start-query'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAthenaClient } from '@/app/api/tools/athena/utils'
|
||||
|
||||
const logger = createLogger('AthenaStartQuery')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsAthenaStartQueryContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
const client = createAthenaClient({
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
})
|
||||
|
||||
const command = new StartQueryExecutionCommand({
|
||||
QueryString: data.queryString,
|
||||
...(data.database || data.catalog
|
||||
? {
|
||||
QueryExecutionContext: {
|
||||
...(data.database && { Database: data.database }),
|
||||
...(data.catalog && { Catalog: data.catalog }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(data.outputLocation
|
||||
? {
|
||||
ResultConfiguration: {
|
||||
OutputLocation: data.outputLocation,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(data.workGroup && { WorkGroup: data.workGroup }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
if (!response.QueryExecutionId) {
|
||||
throw new Error('No query execution ID returned')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
queryExecutionId: response.QueryExecutionId,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Failed to start Athena query')
|
||||
logger.error('StartQuery failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { StopQueryExecutionCommand } from '@aws-sdk/client-athena'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsAthenaStopQueryContract } from '@/lib/api/contracts/tools/aws/athena-stop-query'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createAthenaClient } from '@/app/api/tools/athena/utils'
|
||||
|
||||
const logger = createLogger('AthenaStopQuery')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsAthenaStopQueryContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const data = parsed.data.body
|
||||
|
||||
const client = createAthenaClient({
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
})
|
||||
|
||||
const command = new StopQueryExecutionCommand({
|
||||
QueryExecutionId: data.queryExecutionId,
|
||||
})
|
||||
|
||||
await client.send(command)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Failed to stop Athena query')
|
||||
logger.error('StopQuery failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AthenaClient } from '@aws-sdk/client-athena'
|
||||
|
||||
interface AwsCredentials {
|
||||
region: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
}
|
||||
|
||||
export function createAthenaClient(config: AwsCredentials): AthenaClient {
|
||||
return new AthenaClient({
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { attioListsSelectorContract } from '@/lib/api/contracts/selectors/attio'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('AttioListsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const parsed = await parseRequest(
|
||||
attioListsSelectorContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(error, 'Invalid request') },
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { credential, workflowId } = parsed.data.body
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const 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 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.attio.com/v2/lists', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Attio lists', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Attio lists', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const lists = (data.data || []).map((list: { api_slug: string; name: string }) => ({
|
||||
id: list.api_slug,
|
||||
name: list.name,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ lists })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Attio lists request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Attio lists', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { attioObjectsSelectorContract } from '@/lib/api/contracts/selectors/attio'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('AttioObjectsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const parsed = await parseRequest(
|
||||
attioObjectsSelectorContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(error, 'Invalid request') },
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { credential, workflowId } = parsed.data.body
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const 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 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.attio.com/v2/objects', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Attio objects', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Attio objects', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const objects = (data.data || []).map((obj: { api_slug: string; singular_noun: string }) => ({
|
||||
id: obj.api_slug,
|
||||
name: obj.singular_noun,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ objects })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Attio objects request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Attio objects', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { boxUploadContract } from '@/lib/api/contracts/storage-transfer'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { processFilesToUserFiles, 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'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('BoxUploadAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Box upload attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Authenticated Box upload request via ${authResult.authType}`)
|
||||
|
||||
const parsed = await parseRequest(boxUploadContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
let fileBuffer: Buffer
|
||||
let fileName: string
|
||||
|
||||
if (validatedData.file) {
|
||||
const userFiles = processFilesToUserFiles(
|
||||
[validatedData.file as RawFileInput],
|
||||
requestId,
|
||||
logger
|
||||
)
|
||||
|
||||
if (userFiles.length === 0) {
|
||||
return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userFile = userFiles[0]
|
||||
logger.info(`[${requestId}] Downloading file: ${userFile.name} (${userFile.size} bytes)`)
|
||||
|
||||
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
|
||||
if (denied) return denied
|
||||
try {
|
||||
const result = await downloadServableFileFromStorage(userFile, requestId, logger)
|
||||
fileBuffer = result.buffer
|
||||
} catch (error) {
|
||||
const notReady = docNotReadyResponse(error)
|
||||
if (notReady) return notReady
|
||||
return NextResponse.json(
|
||||
{ success: false, error: getErrorMessage(error, 'Failed to download file') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
fileName = validatedData.fileName || userFile.name
|
||||
} else if (validatedData.fileContent) {
|
||||
logger.info(`[${requestId}] Using legacy base64 content input`)
|
||||
fileBuffer = Buffer.from(validatedData.fileContent, 'base64')
|
||||
fileName = validatedData.fileName || 'file'
|
||||
} else {
|
||||
return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Uploading to Box folder ${validatedData.parentFolderId}: ${fileName} (${fileBuffer.length} bytes)`
|
||||
)
|
||||
|
||||
const attributes = JSON.stringify({
|
||||
name: fileName,
|
||||
parent: { id: validatedData.parentFolderId },
|
||||
})
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('attributes', attributes)
|
||||
formData.append(
|
||||
'file',
|
||||
new Blob([new Uint8Array(fileBuffer)], { type: 'application/octet-stream' }),
|
||||
fileName
|
||||
)
|
||||
|
||||
const response = await fetch('https://upload.box.com/api/2.0/files/content', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = data.message || 'Failed to upload file'
|
||||
logger.error(`[${requestId}] Box API error:`, { status: response.status, data })
|
||||
return NextResponse.json({ success: false, error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const file = data.entries?.[0]
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'No file returned in upload response' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] File uploaded successfully: ${file.name} (ID: ${file.id})`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
id: file.id ?? '',
|
||||
name: file.name ?? '',
|
||||
size: file.size ?? 0,
|
||||
sha1: file.sha1 ?? null,
|
||||
createdAt: file.created_at ?? null,
|
||||
modifiedAt: file.modified_at ?? null,
|
||||
parentId: file.parent?.id ?? null,
|
||||
parentName: file.parent?.name ?? null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Unexpected error:`, error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: getErrorMessage(error, 'Unknown error') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
createMockRequest,
|
||||
hybridAuthMockFns,
|
||||
inputValidationMock,
|
||||
inputValidationMockFns,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockProcessFilesToUserFiles, mockDownloadFileFromStorage, mockAssertToolFileAccess } =
|
||||
vi.hoisted(() => ({
|
||||
mockProcessFilesToUserFiles: vi.fn(),
|
||||
mockDownloadFileFromStorage: vi.fn(),
|
||||
mockAssertToolFileAccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
|
||||
vi.mock('@/lib/uploads/utils/file-utils', () => ({
|
||||
processFilesToUserFiles: mockProcessFilesToUserFiles,
|
||||
}))
|
||||
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
|
||||
downloadServableFileFromStorage: mockDownloadFileFromStorage,
|
||||
}))
|
||||
vi.mock('@/app/api/files/authorization', () => ({
|
||||
assertToolFileAccess: mockAssertToolFileAccess,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/tools/brex/upload-receipt/route'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
const PINNED_IP = '52.216.0.1'
|
||||
|
||||
const baseBody = {
|
||||
apiKey: 'bxt_test_token',
|
||||
expenseId: 'expense_123',
|
||||
file: { key: 'uploads/receipt.pdf', name: 'receipt.pdf', size: 5, type: 'application/pdf' },
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => JSON.stringify(body),
|
||||
json: async () => body,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'internal_jwt',
|
||||
})
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: PINNED_IP,
|
||||
})
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue(jsonResponse({}))
|
||||
mockProcessFilesToUserFiles.mockReturnValue([
|
||||
{ key: 'uploads/receipt.pdf', name: 'receipt.pdf', size: 5, type: 'application/pdf' },
|
||||
])
|
||||
mockAssertToolFileAccess.mockResolvedValue(null)
|
||||
mockDownloadFileFromStorage.mockResolvedValue({
|
||||
buffer: Buffer.from('receipt-bytes'),
|
||||
contentType: 'application/pdf',
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/tools/brex/upload-receipt', () => {
|
||||
it('rejects unauthenticated requests', async () => {
|
||||
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'unauthorized',
|
||||
})
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a receipt upload for an expense and PUTs the file to the pre-signed URL', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ id: 'receipt_1', uri: 'https://s3.example.com/presigned' })
|
||||
)
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data).toEqual({
|
||||
success: true,
|
||||
output: { receiptId: 'receipt_1', receiptName: 'receipt.pdf', expenseId: 'expense_123' },
|
||||
})
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
const [createUrl, createInit] = mockFetch.mock.calls[0]
|
||||
expect(createUrl).toBe('https://api.brex.com/v1/expenses/card/expense_123/receipt_upload')
|
||||
expect(createInit.method).toBe('POST')
|
||||
expect(createInit.headers.Authorization).toBe('Bearer bxt_test_token')
|
||||
expect(JSON.parse(createInit.body)).toEqual({ receipt_name: 'receipt.pdf' })
|
||||
|
||||
expect(inputValidationMockFns.mockValidateUrlWithDNS).toHaveBeenCalledWith(
|
||||
'https://s3.example.com/presigned',
|
||||
'uri'
|
||||
)
|
||||
const [uploadUrl, pinnedIP, uploadInit] =
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0]
|
||||
expect(uploadUrl).toBe('https://s3.example.com/presigned')
|
||||
expect(pinnedIP).toBe(PINNED_IP)
|
||||
expect(uploadInit.method).toBe('PUT')
|
||||
})
|
||||
|
||||
it('rejects a whitespace-only expense ID instead of falling back to receipt match', async () => {
|
||||
const response = await POST(createMockRequest('POST', { ...baseBody, expenseId: ' ' }))
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('trims a padded expense ID before building the upload URL', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ id: 'receipt_5', uri: 'https://s3.example.com/presigned' })
|
||||
)
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('POST', { ...baseBody, expenseId: ' expense_123 ' })
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const [createUrl] = mockFetch.mock.calls[0]
|
||||
expect(createUrl).toBe('https://api.brex.com/v1/expenses/card/expense_123/receipt_upload')
|
||||
const data = await response.json()
|
||||
expect(data.output.expenseId).toBe('expense_123')
|
||||
})
|
||||
|
||||
it('rejects a whitespace-only receipt name', async () => {
|
||||
const response = await POST(createMockRequest('POST', { ...baseBody, receiptName: ' ' }))
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an API key containing header-breaking characters', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('POST', { ...baseBody, apiKey: 'bxt_test\r\nX-Injected: 1' })
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses receipt match when no expense ID is provided', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ id: 'receipt_2', uri: 'https://s3.example.com/presigned' })
|
||||
)
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('POST', { apiKey: 'bxt_test_token', file: baseBody.file })
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.output).toEqual({
|
||||
receiptId: 'receipt_2',
|
||||
receiptName: 'receipt.pdf',
|
||||
expenseId: null,
|
||||
})
|
||||
|
||||
const [createUrl] = mockFetch.mock.calls[0]
|
||||
expect(createUrl).toBe('https://api.brex.com/v1/expenses/card/receipt_match')
|
||||
})
|
||||
|
||||
it('honors a receipt name override', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ id: 'receipt_3', uri: 'https://s3.example.com/presigned' })
|
||||
)
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('POST', { ...baseBody, receiptName: 'march-dinner.pdf' })
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const [, createInit] = mockFetch.mock.calls[0]
|
||||
expect(JSON.parse(createInit.body)).toEqual({ receipt_name: 'march-dinner.pdf' })
|
||||
})
|
||||
|
||||
it('propagates Brex API errors', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ message: 'Expense not found' }, 404))
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(404)
|
||||
const data = await response.json()
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('Expense not found')
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects files over the 50 MB limit', async () => {
|
||||
mockDownloadFileFromStorage.mockResolvedValueOnce({
|
||||
buffer: Buffer.alloc(50 * 1024 * 1024 + 1),
|
||||
contentType: 'application/pdf',
|
||||
})
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.error).toContain('50 MB')
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks pre-signed URLs that fail SSRF validation', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ id: 'receipt_6', uri: 'https://169.254.169.254/latest/meta-data' })
|
||||
)
|
||||
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValueOnce({
|
||||
isValid: false,
|
||||
error: 'uri resolves to a blocked IP address',
|
||||
})
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(502)
|
||||
const data = await response.json()
|
||||
expect(data.error).toContain('invalid upload URL')
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails when the pre-signed upload fails', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({ id: 'receipt_4', uri: 'https://s3.example.com/presigned' })
|
||||
)
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(jsonResponse({}, 403))
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(502)
|
||||
const data = await response.json()
|
||||
expect(data.success).toBe(false)
|
||||
})
|
||||
|
||||
it('denies access to files the caller cannot read', async () => {
|
||||
const deniedResponse = new Response(
|
||||
JSON.stringify({ success: false, error: 'File not found' }),
|
||||
{
|
||||
status: 404,
|
||||
}
|
||||
)
|
||||
mockAssertToolFileAccess.mockResolvedValueOnce(deniedResponse)
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { brexUploadReceiptContract } from '@/lib/api/contracts/tools/brex'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { processFilesToUserFiles, 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 { BREX_API_BASE, buildBrexHeaders } from '@/tools/brex/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('BrexUploadReceiptAPI')
|
||||
|
||||
const MAX_RECEIPT_SIZE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Brex receipt upload attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(brexUploadReceiptContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { apiKey, expenseId, file, receiptName } = parsed.data.body
|
||||
|
||||
const userFiles = processFilesToUserFiles([file as RawFileInput], requestId, logger)
|
||||
if (userFiles.length === 0) {
|
||||
return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 })
|
||||
}
|
||||
|
||||
const userFile = userFiles[0]
|
||||
const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger)
|
||||
if (denied) return denied
|
||||
|
||||
let fileBuffer: Buffer
|
||||
try {
|
||||
const resolved = await downloadServableFileFromStorage(userFile, requestId, logger)
|
||||
fileBuffer = resolved.buffer
|
||||
} catch (error) {
|
||||
const notReady = docNotReadyResponse(error)
|
||||
if (notReady) return notReady
|
||||
logger.error(`[${requestId}] Failed to download receipt file:`, error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: getErrorMessage(error, 'Unknown error') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
if (fileBuffer.length > MAX_RECEIPT_SIZE_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Receipt file exceeds the 50 MB limit' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const effectiveReceiptName = receiptName || userFile.name
|
||||
const endpoint = expenseId
|
||||
? `${BREX_API_BASE}/v1/expenses/card/${encodeURIComponent(expenseId)}/receipt_upload`
|
||||
: `${BREX_API_BASE}/v1/expenses/card/receipt_match`
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Creating Brex ${expenseId ? 'receipt upload' : 'receipt match'}: ${effectiveReceiptName} (${fileBuffer.length} bytes)`
|
||||
)
|
||||
|
||||
const createResponse = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: buildBrexHeaders(apiKey),
|
||||
body: JSON.stringify({ receipt_name: effectiveReceiptName }),
|
||||
})
|
||||
|
||||
if (!createResponse.ok) {
|
||||
const errorText = await createResponse.text()
|
||||
logger.error(`[${requestId}] Brex API error:`, {
|
||||
status: createResponse.status,
|
||||
error: errorText,
|
||||
})
|
||||
let message = errorText
|
||||
try {
|
||||
message = JSON.parse(errorText).message ?? errorText
|
||||
} catch {
|
||||
message = errorText
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Brex API error (${createResponse.status}): ${message}` },
|
||||
{ status: createResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
const createData = await createResponse.json()
|
||||
if (!createData.uri || !createData.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Brex did not return an upload URL' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const uriValidation = await validateUrlWithDNS(createData.uri, 'uri')
|
||||
if (!uriValidation.isValid) {
|
||||
logger.error(`[${requestId}] Pre-signed upload URL failed SSRF validation:`, {
|
||||
error: uriValidation.error,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Brex returned an invalid upload URL' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const uploadResponse = await secureFetchWithPinnedIP(
|
||||
createData.uri,
|
||||
uriValidation.resolvedIP!,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: new Uint8Array(fileBuffer),
|
||||
}
|
||||
)
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
logger.error(`[${requestId}] Receipt upload to pre-signed URL failed:`, {
|
||||
status: uploadResponse.status,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Failed to upload receipt file (${uploadResponse.status})` },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Receipt uploaded successfully (ID: ${createData.id})`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
receiptId: createData.id,
|
||||
receiptName: effectiveReceiptName,
|
||||
expenseId: expenseId ?? null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Unexpected error:`, error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: getErrorMessage(error, 'Unknown error') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { calcomEventTypesSelectorContract } from '@/lib/api/contracts/selectors'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('CalcomEventTypesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface CalcomEventType {
|
||||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const parsed = await parseRequest(calcomEventTypesSelectorContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { credential, workflowId } = parsed.data.body
|
||||
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const 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 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.cal.com/v2/event-types', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'cal-api-version': '2024-06-14',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Cal.com event types', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Cal.com event types', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { data?: CalcomEventType[] }
|
||||
const eventTypes = (data.data || []).map((eventType) => ({
|
||||
id: String(eventType.id),
|
||||
title: eventType.title,
|
||||
slug: eventType.slug,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ eventTypes })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Cal.com event types request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Cal.com event types', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { calcomSchedulesSelectorContract } from '@/lib/api/contracts/selectors'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('CalcomSchedulesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface CalcomSchedule {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const parsed = await parseRequest(calcomSchedulesSelectorContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { credential, workflowId } = parsed.data.body
|
||||
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const 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 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.cal.com/v2/schedules', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'cal-api-version': '2024-06-11',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Cal.com schedules', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Cal.com schedules', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { data?: CalcomSchedule[] }
|
||||
const schedules = (data.data || []).map((schedule) => ({
|
||||
id: String(schedule.id),
|
||||
name: schedule.name,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ schedules })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Cal.com schedules request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Cal.com schedules', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseCountRowsContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseCountRows } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseCountRowsAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse count rows attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseCountRowsContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const count = await executeClickHouseCountRows(params, params.table, params.where)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Table contains ${count} row(s).`,
|
||||
count,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse count rows failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse count rows failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseCreateDatabaseContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseCreateDatabase } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseCreateDatabaseAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse create database attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseCreateDatabaseContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
await executeClickHouseCreateDatabase(params, params.name)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Database '${params.name}' created.`,
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse create database failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse create database failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseCreateTableContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseCreateTable } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseCreateTableAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse create table attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseCreateTableContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
await executeClickHouseCreateTable(
|
||||
params,
|
||||
params.table,
|
||||
params.columns,
|
||||
params.engine,
|
||||
params.orderBy,
|
||||
params.partitionBy
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Table '${params.table}' created.`,
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse create table failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse create table failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseDeleteContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseDelete } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseDeleteAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse delete attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseDeleteContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const result = await executeClickHouseDelete(params, params.table, params.where)
|
||||
|
||||
logger.info(`[${requestId}] Delete mutation submitted, ${result.rowCount} row(s) affected`)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Delete mutation submitted. ClickHouse mutations run asynchronously. ${result.rowCount} row(s) affected.`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse delete failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse delete failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseDescribeTableContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseDescribeTable } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseDescribeTableAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse describe table attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseDescribeTableContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeClickHouseDescribeTable(params, params.table)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Described table with ${result.rowCount} column(s).`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse describe table failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse describe table failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseDropDatabaseContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseDropDatabase } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseDropDatabaseAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse drop database attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseDropDatabaseContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
await executeClickHouseDropDatabase(params, params.name)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Database '${params.name}' dropped.`,
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse drop database failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse drop database failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseDropPartitionContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseDropPartition } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseDropPartitionAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse drop partition attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseDropPartitionContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
await executeClickHouseDropPartition(params, params.table, params.partition)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Dropped partition from table '${params.table}'.`,
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse drop partition failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse drop partition failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseDropTableContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseDropTable } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseDropTableAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse drop table attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseDropTableContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
await executeClickHouseDropTable(params, params.table)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Table '${params.table}' dropped.`,
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse drop table failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse drop table failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseExecuteContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseQuery } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseExecuteAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse execute attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseExecuteContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Executing ClickHouse statement on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const result = await executeClickHouseQuery(params, params.query)
|
||||
|
||||
logger.info(`[${requestId}] Statement executed successfully, ${result.rowCount} row(s)`)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Statement executed successfully. ${result.rowCount} row(s) returned or affected.`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse execute failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse execute failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseInsertRowsContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseInsertRows } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseInsertRowsAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse insert rows attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseInsertRowsContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeClickHouseInsertRows(params, params.table, params.rows)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Inserted ${result.rowCount} row(s) into '${params.table}'.`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse insert rows failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse insert rows failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseInsertContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseInsert } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseInsertAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse insert attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseInsertContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const result = await executeClickHouseInsert(params, params.table, params.data)
|
||||
|
||||
logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Data inserted successfully. ${result.rowCount} row(s) affected.`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse insert failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse insert failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseIntrospectContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseIntrospect } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseIntrospectAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse introspect attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseIntrospectContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Introspecting ClickHouse schema on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const result = await executeClickHouseIntrospect(params)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Introspection completed successfully, found ${result.tables.length} tables`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Schema introspection completed. Found ${result.tables.length} table(s) in database '${params.database}'.`,
|
||||
tables: result.tables,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse introspection failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse introspection failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseKillQueryContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseKillQuery } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseKillQueryAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse kill query attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseKillQueryContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeClickHouseKillQuery(params, params.queryId)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Kill command executed for query '${params.queryId}'.`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse kill query failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse kill query failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseListClustersContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseListClusters } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseListClustersAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse list clusters attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseListClustersContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeClickHouseListClusters(params)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Found ${result.rowCount} cluster node(s).`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse list clusters failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse list clusters failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseListDatabasesContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseListDatabases } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseListDatabasesAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse list databases attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseListDatabasesContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeClickHouseListDatabases(params)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Found ${result.rowCount} database(s).`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse list databases failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse list databases failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseListMutationsContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseListMutations } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseListMutationsAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse list mutations attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseListMutationsContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeClickHouseListMutations(params, params.table, params.onlyRunning)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Found ${result.rowCount} mutation(s).`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse list mutations failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse list mutations failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseListPartitionsContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseListPartitions } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseListPartitionsAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse list partitions attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseListPartitionsContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeClickHouseListPartitions(params, params.table)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Found ${result.rowCount} partition(s).`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse list partitions failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse list partitions failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { clickhouseListRunningQueriesContract } from '@/lib/api/contracts/tools/databases/clickhouse'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { executeClickHouseListRunningQueries } from '@/app/api/tools/clickhouse/utils'
|
||||
|
||||
const logger = createLogger('ClickHouseListRunningQueriesAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateId().slice(0, 8)
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized ClickHouse list running queries attempt`)
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(clickhouseListRunningQueriesContract, request, { logger })
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeClickHouseListRunningQueries(params)
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Found ${result.rowCount} running query(ies).`,
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error(`[${requestId}] ClickHouse list running queries failed:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: `ClickHouse list running queries failed: ${errorMessage}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user