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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,216 @@
/**
* @vitest-environment node
*/
import {
auditMock,
createMockRequest,
hybridAuthMockFns,
knowledgeApiUtilsMock,
knowledgeApiUtilsMockFns,
requestUtilsMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDbChain } = vi.hoisted(() => {
const chain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue([]),
limit: vi.fn().mockResolvedValue([]),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
}
return { mockDbChain: chain }
})
const mockCheckAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess
const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess
vi.mock('@sim/db', () => ({ db: mockDbChain }))
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
vi.mock('@sim/audit', () => auditMock)
import { GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/documents/route'
describe('Connector Documents API Route', () => {
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-req-id')
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockResolvedValue([])
mockDbChain.limit.mockResolvedValue([])
mockDbChain.update.mockReturnThis()
mockDbChain.set.mockReturnThis()
mockDbChain.returning.mockResolvedValue([])
})
describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: false,
userId: null,
})
const req = createMockRequest('GET')
const response = await GET(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when connector not found', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const response = await GET(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns documents list on success', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckAccess.mockResolvedValue({ hasAccess: true })
const doc = { id: 'doc-1', filename: 'test.txt', userExcluded: false }
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.orderBy.mockResolvedValueOnce([doc])
const url = 'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents'
const req = createMockRequest('GET', undefined, undefined, url)
const response = await GET(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.documents).toHaveLength(1)
expect(data.data.counts.active).toBe(1)
expect(data.data.counts.excluded).toBe(0)
})
it('includes excluded documents when includeExcluded=true', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.orderBy
.mockResolvedValueOnce([{ id: 'doc-1', userExcluded: false }])
.mockResolvedValueOnce([{ id: 'doc-2', userExcluded: true }])
const url =
'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents?includeExcluded=true'
const req = createMockRequest('GET', undefined, undefined, url)
const response = await GET(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.documents).toHaveLength(2)
expect(data.data.counts.active).toBe(1)
expect(data.data.counts.excluded).toBe(1)
})
})
describe('PATCH', () => {
it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: false,
userId: null,
})
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 400 for invalid body', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
const req = createMockRequest('PATCH', { documentIds: [] })
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(400)
})
it('returns 404 when connector not found', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const response = await PATCH(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns success for restore operation', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
userName: 'Test',
userEmail: 'test@test.com',
})
mockCheckWriteAccess.mockResolvedValue({
hasAccess: true,
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
})
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-1' }])
const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] })
const response = await PATCH(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.restoredCount).toBe(1)
})
it('returns success for exclude operation', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
userName: 'Test',
userEmail: 'test@test.com',
})
mockCheckWriteAccess.mockResolvedValue({
hasAccess: true,
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
})
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-2' }, { id: 'doc-3' }])
const req = createMockRequest('PATCH', {
operation: 'exclude',
documentIds: ['doc-2', 'doc-3'],
})
const response = await PATCH(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.excludedCount).toBe(2)
expect(data.data.documentIds).toEqual(['doc-2', 'doc-3'])
})
})
})
@@ -0,0 +1,247 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { document, knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { patchKnowledgeConnectorDocumentsContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('ConnectorDocumentsAPI')
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
/**
* GET /api/knowledge/[id]/connectors/[connectorId]/documents
* Returns documents for a connector, optionally including user-excluded ones.
*/
export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectorRows = await db
.select({ id: knowledgeConnector.id })
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const includeExcluded = request.nextUrl.searchParams.get('includeExcluded') === 'true'
const activeDocs = await db
.select({
id: document.id,
filename: document.filename,
externalId: document.externalId,
sourceUrl: document.sourceUrl,
enabled: document.enabled,
userExcluded: document.userExcluded,
uploadedAt: document.uploadedAt,
processingStatus: document.processingStatus,
})
.from(document)
.where(
and(
eq(document.connectorId, connectorId),
isNull(document.archivedAt),
isNull(document.deletedAt),
eq(document.userExcluded, false)
)
)
.orderBy(document.filename)
const excludedDocs = includeExcluded
? await db
.select({
id: document.id,
filename: document.filename,
externalId: document.externalId,
sourceUrl: document.sourceUrl,
enabled: document.enabled,
userExcluded: document.userExcluded,
uploadedAt: document.uploadedAt,
processingStatus: document.processingStatus,
})
.from(document)
.where(
and(
eq(document.connectorId, connectorId),
eq(document.userExcluded, true),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
.orderBy(document.filename)
: []
const docs = [...activeDocs, ...excludedDocs]
const activeCount = activeDocs.length
const excludedCount = excludedDocs.length
return NextResponse.json({
success: true,
data: {
documents: docs,
counts: { active: activeCount, excluded: excludedCount },
},
})
} catch (error) {
logger.error(`[${requestId}] Error fetching connector documents`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
/**
* PATCH /api/knowledge/[id]/connectors/[connectorId]/documents
* Restore or exclude connector documents.
*/
export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await context.params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectorRows = await db
.select({ id: knowledgeConnector.id })
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const parsed = await parseRequest(patchKnowledgeConnectorDocumentsContract, request, context)
if (!parsed.success) return parsed.response
const { operation, documentIds } = parsed.data.body
if (operation === 'restore') {
const updated = await db
.update(document)
.set({ userExcluded: false, enabled: true })
.where(
and(
eq(document.connectorId, connectorId),
inArray(document.id, documentIds),
eq(document.userExcluded, true),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
.returning({ id: document.id })
logger.info(`[${requestId}] Restored ${updated.length} excluded documents`, { connectorId })
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.CONNECTOR_DOCUMENT_RESTORED,
resourceType: AuditResourceType.CONNECTOR,
resourceId: connectorId,
description: `Restored ${updated.length} excluded document(s) for knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: {
knowledgeBaseId,
knowledgeBaseName: writeCheck.knowledgeBase.name,
operation: 'restore',
documentCount: updated.length,
documentIds: updated.map((d) => d.id),
},
request,
})
return NextResponse.json({
success: true,
data: { restoredCount: updated.length, documentIds: updated.map((d) => d.id) },
})
}
const updated = await db
.update(document)
.set({ userExcluded: true, enabled: false })
.where(
and(
eq(document.connectorId, connectorId),
inArray(document.id, documentIds),
eq(document.userExcluded, false),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
.returning({ id: document.id })
logger.info(`[${requestId}] Excluded ${updated.length} documents`, { connectorId })
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.CONNECTOR_DOCUMENT_EXCLUDED,
resourceType: AuditResourceType.CONNECTOR,
resourceId: connectorId,
description: `Excluded ${updated.length} document(s) from knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: {
knowledgeBaseId,
knowledgeBaseName: writeCheck.knowledgeBase.name,
operation: 'exclude',
documentCount: updated.length,
documentIds: updated.map((d) => d.id),
},
request,
})
return NextResponse.json({
success: true,
data: { excludedCount: updated.length, documentIds: updated.map((d) => d.id) },
})
} catch (error) {
logger.error(`[${requestId}] Error updating connector documents`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,246 @@
/**
* @vitest-environment node
*/
import {
auditMock,
authOAuthUtilsMock,
createMockRequest,
hybridAuthMockFns,
knowledgeApiUtilsMock,
knowledgeApiUtilsMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDbChain, mockValidateConfig } = vi.hoisted(() => {
const chain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]),
execute: vi.fn().mockResolvedValue(undefined),
transaction: vi.fn(),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockReturnThis(),
delete: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
}
return {
mockDbChain: chain,
mockValidateConfig: vi.fn(),
}
})
const mockCheckAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess
const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess
vi.mock('@sim/db', () => ({ db: mockDbChain }))
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock)
vi.mock('@/connectors/registry.server', () => ({
CONNECTOR_REGISTRY: {
jira: { validateConfig: mockValidateConfig },
},
}))
vi.mock('@/lib/knowledge/tags/service', () => ({
cleanupUnusedTagDefinitions: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/knowledge/documents/service', () => ({
deleteDocumentStorageFiles: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@sim/audit', () => auditMock)
import { DELETE, GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/route'
describe('Knowledge Connector By ID API Route', () => {
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockReturnThis()
mockDbChain.limit.mockResolvedValue([])
mockDbChain.execute.mockResolvedValue(undefined)
mockDbChain.transaction.mockImplementation(
async (callback: (tx: typeof mockDbChain) => unknown) => callback(mockDbChain)
)
mockDbChain.update.mockReturnThis()
mockDbChain.delete.mockReturnThis()
mockDbChain.set.mockReturnThis()
mockDbChain.returning.mockResolvedValue([])
})
describe('GET', () => {
it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: false,
userId: null,
})
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when KB not found', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckAccess.mockResolvedValue({ hasAccess: false, notFound: true })
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 404 when connector not found', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns connector with sync logs on success', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckAccess.mockResolvedValue({ hasAccess: true })
const mockConnector = { id: 'conn-456', connectorType: 'jira', status: 'active' }
const mockLogs = [{ id: 'log-1', status: 'completed' }]
mockDbChain.limit.mockResolvedValueOnce([mockConnector]).mockResolvedValueOnce(mockLogs)
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.id).toBe('conn-456')
expect(data.data.syncLogs).toHaveLength(1)
})
})
describe('PATCH', () => {
it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: false,
userId: null,
})
const req = createMockRequest('PATCH', { status: 'paused' })
const response = await PATCH(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 400 for invalid body', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
const req = createMockRequest('PATCH', { syncIntervalMinutes: 'not a number' })
const response = await PATCH(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Validation error')
})
it('returns 404 when connector not found during sourceConfig validation', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } })
const response = await PATCH(req, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 200 and updates status', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
userName: 'Test',
userEmail: 'test@test.com',
})
mockCheckWriteAccess.mockResolvedValue({
hasAccess: true,
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
})
const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 120 }
mockDbChain.limit.mockResolvedValueOnce([updatedConnector])
const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 120 })
const response = await PATCH(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.status).toBe('paused')
})
})
describe('DELETE', () => {
it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: false,
userId: null,
})
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 200 on successful hard-delete', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
userName: 'Test',
userEmail: 'test@test.com',
})
mockCheckWriteAccess.mockResolvedValue({
hasAccess: true,
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
})
mockDbChain.where
.mockReturnValueOnce(mockDbChain)
.mockResolvedValueOnce([{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }])
.mockReturnValueOnce(mockDbChain)
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }])
mockDbChain.returning.mockResolvedValueOnce([{ id: 'conn-456' }])
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
})
})
@@ -0,0 +1,420 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import {
document,
embedding,
knowledgeBase,
knowledgeConnector,
knowledgeConnectorSyncLog,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { updateKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { decryptApiKey } from '@/lib/api-key/crypto'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
import { captureServerEvent } from '@/lib/posthog/server'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
import { CONNECTOR_REGISTRY } from '@/connectors/registry.server'
const logger = createLogger('KnowledgeConnectorByIdAPI')
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
/**
* GET /api/knowledge/[id]/connectors/[connectorId] - Get connector details with recent sync logs
*/
export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectorRows = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const syncLogs = await db
.select()
.from(knowledgeConnectorSyncLog)
.where(eq(knowledgeConnectorSyncLog.connectorId, connectorId))
.orderBy(desc(knowledgeConnectorSyncLog.startedAt))
.limit(10)
const { encryptedApiKey: _, ...connectorData } = connectorRows[0]
return NextResponse.json({
success: true,
data: {
...connectorData,
syncLogs,
},
})
} catch (error) {
logger.error(`[${requestId}] Error fetching connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
/**
* PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector
*/
export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteParams) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await context.params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const parsed = await parseRequest(updateKnowledgeConnectorContract, request, context)
if (!parsed.success) return parsed.response
const body = parsed.data.body
if (
body.syncIntervalMinutes !== undefined &&
body.syncIntervalMinutes > 0 &&
body.syncIntervalMinutes < 60
) {
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
if (!canUseLiveSync) {
return NextResponse.json(
{ error: 'Live sync requires a Max or Enterprise plan' },
{ status: 403 }
)
}
}
if (body.sourceConfig !== undefined) {
const existingRows = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (existingRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const existing = existingRows[0]
const connectorConfig = CONNECTOR_REGISTRY[existing.connectorType]
if (!connectorConfig) {
return NextResponse.json(
{ error: `Unknown connector type: ${existing.connectorType}` },
{ status: 400 }
)
}
const kbRows = await db
.select({ userId: knowledgeBase.userId })
.from(knowledgeBase)
.where(eq(knowledgeBase.id, knowledgeBaseId))
.limit(1)
if (kbRows.length === 0) {
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
let accessToken: string | null = null
if (connectorConfig.auth.mode === 'apiKey') {
if (!existing.encryptedApiKey) {
return NextResponse.json(
{ error: 'API key not found. Please reconfigure the connector.' },
{ status: 400 }
)
}
accessToken = (await decryptApiKey(existing.encryptedApiKey)).decrypted
} else {
if (!existing.credentialId) {
return NextResponse.json(
{ error: 'OAuth credential not found. Please reconfigure the connector.' },
{ status: 400 }
)
}
accessToken = await refreshAccessTokenIfNeeded(
existing.credentialId,
kbRows[0].userId,
`patch-${connectorId}`
)
}
if (!accessToken) {
return NextResponse.json(
{ error: 'Failed to refresh access token. Please reconnect your account.' },
{ status: 401 }
)
}
const validation = await connectorConfig.validateConfig(accessToken, body.sourceConfig)
if (!validation.valid) {
return NextResponse.json(
{ error: validation.error || 'Invalid source configuration' },
{ status: 400 }
)
}
}
const updates: Record<string, unknown> = { updatedAt: new Date() }
if (body.sourceConfig !== undefined) {
updates.sourceConfig = body.sourceConfig
}
if (body.syncIntervalMinutes !== undefined) {
updates.syncIntervalMinutes = body.syncIntervalMinutes
if (body.syncIntervalMinutes > 0) {
updates.nextSyncAt = new Date(Date.now() + body.syncIntervalMinutes * 60 * 1000)
} else {
updates.nextSyncAt = null
}
}
if (body.status !== undefined) {
updates.status = body.status
if (body.status === 'active') {
updates.consecutiveFailures = 0
updates.lastSyncError = null
if (updates.nextSyncAt === undefined) {
updates.nextSyncAt = new Date()
}
}
}
await db
.update(knowledgeConnector)
.set(updates)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
const updated = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
const { encryptedApiKey: __, ...updatedData } = updated[0]
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.CONNECTOR_UPDATED,
resourceType: AuditResourceType.CONNECTOR,
resourceId: connectorId,
resourceName: updatedData.connectorType,
description: `Updated connector for knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: {
knowledgeBaseId,
knowledgeBaseName: writeCheck.knowledgeBase.name,
connectorType: updatedData.connectorType,
updatedFields: Object.keys(parsed.data),
...(body.syncIntervalMinutes !== undefined && {
syncIntervalMinutes: body.syncIntervalMinutes,
}),
...(body.status !== undefined && { newStatus: body.status }),
},
request,
})
return NextResponse.json({ success: true, data: updatedData })
} catch (error) {
logger.error(`[${requestId}] Error updating connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
/**
* DELETE /api/knowledge/[id]/connectors/[connectorId] - Hard-delete a connector
*/
export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, connectorId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const existingConnector = await db
.select({ id: knowledgeConnector.id, connectorType: knowledgeConnector.connectorType })
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (existingConnector.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
const { searchParams } = new URL(request.url)
const deleteDocuments = searchParams.get('deleteDocuments') === 'true'
const { deletedDocs, docCount } = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`)
const docs = await tx
.select({ id: document.id, fileUrl: document.fileUrl })
.from(document)
.where(
and(
eq(document.connectorId, connectorId),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
if (deleteDocuments) {
const documentIds = docs.map((doc) => doc.id)
if (documentIds.length > 0) {
await tx.delete(embedding).where(inArray(embedding.documentId, documentIds))
await tx.delete(document).where(inArray(document.id, documentIds))
}
}
const deletedConnectors = await tx
.delete(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.returning({ id: knowledgeConnector.id })
if (deletedConnectors.length === 0) {
throw new Error('Connector not found')
}
return { deletedDocs: deleteDocuments ? docs : [], docCount: docs.length }
})
const kbWorkspaceId = writeCheck.knowledgeBase?.workspaceId ?? null
if (deleteDocuments) {
await Promise.all([
deletedDocs.length > 0
? deleteDocumentStorageFiles(
deletedDocs.map((doc) => ({ ...doc, workspaceId: kbWorkspaceId })),
requestId
)
: Promise.resolve(),
cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => {
logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error)
}),
])
}
logger.info(
`[${requestId}] Deleted connector ${connectorId}${deleteDocuments ? ` and ${docCount} documents` : `, kept ${docCount} documents`}`
)
captureServerEvent(
auth.userId,
'knowledge_base_connector_removed',
{
knowledge_base_id: knowledgeBaseId,
workspace_id: kbWorkspaceId ?? '',
connector_type: existingConnector[0].connectorType,
documents_deleted: deleteDocuments ? docCount : 0,
},
kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
)
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.CONNECTOR_DELETED,
resourceType: AuditResourceType.CONNECTOR,
resourceId: connectorId,
resourceName: existingConnector[0].connectorType,
description: `Deleted connector from knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: {
knowledgeBaseId,
knowledgeBaseName: writeCheck.knowledgeBase.name,
connectorType: existingConnector[0].connectorType,
deleteDocuments,
documentsDeleted: deleteDocuments ? docCount : 0,
documentsKept: deleteDocuments ? 0 : docCount,
},
request,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Error deleting connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,117 @@
/**
* @vitest-environment node
*/
import {
auditMock,
createMockRequest,
hybridAuthMockFns,
knowledgeApiUtilsMock,
knowledgeApiUtilsMockFns,
requestUtilsMockFns,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDispatchSync, mockDbChain } = vi.hoisted(() => {
const chain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue([]),
limit: vi.fn().mockResolvedValue([]),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
}
return {
mockDispatchSync: vi.fn().mockResolvedValue(undefined),
mockDbChain: chain,
}
})
const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess
vi.mock('@sim/db', () => ({ db: mockDbChain }))
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
vi.mock('@/lib/knowledge/connectors/sync-engine', () => ({
dispatchSync: mockDispatchSync,
}))
vi.mock('@sim/audit', () => auditMock)
import { POST } from '@/app/api/knowledge/[id]/connectors/[connectorId]/sync/route'
describe('Connector Manual Sync API Route', () => {
const mockParams = Promise.resolve({ id: 'kb-123', connectorId: 'conn-456' })
beforeEach(() => {
vi.clearAllMocks()
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-req-id')
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.orderBy.mockResolvedValue([])
mockDbChain.limit.mockResolvedValue([])
mockDbChain.update.mockReturnThis()
mockDbChain.set.mockReturnThis()
})
it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: false,
userId: null,
})
const req = createMockRequest('POST')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(401)
})
it('returns 404 when connector not found', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([])
const req = createMockRequest('POST')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(404)
})
it('returns 409 when connector is syncing', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
})
mockCheckWriteAccess.mockResolvedValue({ hasAccess: true })
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }])
const req = createMockRequest('POST')
const response = await POST(req as never, { params: mockParams })
expect(response.status).toBe(409)
})
it('dispatches sync on valid request', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
userName: 'Test',
userEmail: 'test@test.com',
})
mockCheckWriteAccess.mockResolvedValue({
hasAccess: true,
knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' },
})
mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }])
const req = createMockRequest('POST')
const response = await POST(req as never, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockDispatchSync).toHaveBeenCalledWith('conn-456', { requestId: 'test-req-id' })
})
})
@@ -0,0 +1,111 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { triggerKnowledgeConnectorSyncContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
import { captureServerEvent } from '@/lib/posthog/server'
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('ConnectorManualSyncAPI')
type RouteParams = { params: Promise<{ id: string; connectorId: string }> }
/**
* POST /api/knowledge/[id]/connectors/[connectorId]/sync - Trigger a manual sync
*/
export const POST = withRouteHandler(async (request: NextRequest, context: RouteParams) => {
const requestId = generateRequestId()
const parsed = await parseRequest(triggerKnowledgeConnectorSyncContract, request, context)
if (!parsed.success) return parsed.response
const { id: knowledgeBaseId, connectorId } = parsed.data.params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status })
}
const connectorRows = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.id, connectorId),
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.limit(1)
if (connectorRows.length === 0) {
return NextResponse.json({ error: 'Connector not found' }, { status: 404 })
}
if (connectorRows[0].status === 'syncing') {
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 })
}
logger.info(`[${requestId}] Manual sync triggered for connector ${connectorId}`)
const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? ''
captureServerEvent(
auth.userId,
'knowledge_base_connector_synced',
{
knowledge_base_id: knowledgeBaseId,
workspace_id: kbWorkspaceId,
connector_type: connectorRows[0].connectorType,
},
kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
)
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.CONNECTOR_SYNCED,
resourceType: AuditResourceType.CONNECTOR,
resourceId: connectorId,
resourceName: connectorRows[0].connectorType,
description: `Triggered manual sync for connector on knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: {
knowledgeBaseId,
knowledgeBaseName: writeCheck.knowledgeBase.name,
connectorType: connectorRows[0].connectorType,
connectorStatus: connectorRows[0].status,
syncType: 'manual',
},
request,
})
dispatchSync(connectorId, { requestId }).catch((error) => {
logger.error(
`[${requestId}] Failed to dispatch manual sync for connector ${connectorId}`,
error
)
})
return NextResponse.json({
success: true,
message: 'Sync triggered',
})
} catch (error) {
logger.error(`[${requestId}] Error triggering manual sync`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
@@ -0,0 +1,320 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { knowledgeBase, knowledgeBaseTagDefinitions, knowledgeConnector } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, desc, eq, isNull, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createKnowledgeConnectorContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { encryptApiKey } from '@/lib/api-key/crypto'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
import { allocateTagSlots } from '@/lib/knowledge/constants'
import { createTagDefinition } from '@/lib/knowledge/tags/service'
import { captureServerEvent } from '@/lib/posthog/server'
import { getCredential } from '@/app/api/auth/oauth/utils'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
import { CONNECTOR_REGISTRY } from '@/connectors/registry.server'
const logger = createLogger('KnowledgeConnectorsAPI')
/**
* GET /api/knowledge/[id]/connectors - List connectors for a knowledge base
*/
export const GET = withRouteHandler(
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId } = await params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401
return NextResponse.json(
{ error: status === 404 ? 'Not found' : 'Unauthorized' },
{ status }
)
}
const connectors = await db
.select()
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
.orderBy(desc(knowledgeConnector.createdAt))
return NextResponse.json({
success: true,
data: connectors.map(({ encryptedApiKey: _, ...rest }) => rest),
})
} catch (error) {
logger.error(`[${requestId}] Error listing connectors`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
/**
* POST /api/knowledge/[id]/connectors - Create a new connector
*/
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId } = await context.params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!writeCheck.hasAccess) {
const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401
return NextResponse.json(
{ error: status === 404 ? 'Not found' : 'Unauthorized' },
{ status }
)
}
const parsed = await parseRequest(createKnowledgeConnectorContract, request, context)
if (!parsed.success) return parsed.response
const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } =
parsed.data.body
if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) {
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
if (!canUseLiveSync) {
return NextResponse.json(
{ error: 'Live sync requires a Max or Enterprise plan' },
{ status: 403 }
)
}
}
const connectorConfig = CONNECTOR_REGISTRY[connectorType]
if (!connectorConfig) {
return NextResponse.json(
{ error: `Unknown connector type: ${connectorType}` },
{ status: 400 }
)
}
let resolvedCredentialId: string | null = null
let resolvedEncryptedApiKey: string | null = null
let accessToken: string
if (connectorConfig.auth.mode === 'apiKey') {
if (!apiKey) {
return NextResponse.json({ error: 'API key is required' }, { status: 400 })
}
accessToken = apiKey
} else {
if (!credentialId) {
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const credential = await getCredential(requestId, credentialId, auth.userId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 400 })
}
if (!credential.accessToken) {
return NextResponse.json(
{ error: 'Credential has no access token. Please reconnect your account.' },
{ status: 400 }
)
}
accessToken = credential.accessToken
resolvedCredentialId = credentialId
}
const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig)
if (!configValidation.valid) {
return NextResponse.json(
{ error: configValidation.error || 'Invalid source configuration' },
{ status: 400 }
)
}
let finalSourceConfig: Record<string, unknown> = { ...sourceConfig }
if (connectorConfig.auth.mode === 'apiKey' && apiKey) {
const { encrypted } = await encryptApiKey(apiKey)
resolvedEncryptedApiKey = encrypted
}
const tagSlotMapping: Record<string, string> = {}
let newTagSlots: Record<string, string> = {}
if (connectorConfig.tagDefinitions?.length) {
const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? [])
const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id))
const existingDefs = await db
.select({
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
displayName: knowledgeBaseTagDefinitions.displayName,
fieldType: knowledgeBaseTagDefinitions.fieldType,
})
.from(knowledgeBaseTagDefinitions)
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
const usedSlots = new Set<string>(existingDefs.map((d) => d.tagSlot))
const existingByName = new Map(
existingDefs.map((d) => [d.displayName, { tagSlot: d.tagSlot, fieldType: d.fieldType }])
)
const defsNeedingSlots: typeof enabledDefs = []
for (const td of enabledDefs) {
const existing = existingByName.get(td.displayName)
if (existing && existing.fieldType === td.fieldType) {
tagSlotMapping[td.id] = existing.tagSlot
} else {
defsNeedingSlots.push(td)
}
}
const { mapping, skipped: skippedTags } = allocateTagSlots(defsNeedingSlots, usedSlots)
Object.assign(tagSlotMapping, mapping)
newTagSlots = mapping
for (const name of skippedTags) {
logger.warn(`[${requestId}] No available slots for "${name}"`)
}
if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) {
return NextResponse.json(
{ error: `No available tag slots. Could not assign: ${skippedTags.join(', ')}` },
{ status: 422 }
)
}
finalSourceConfig = { ...finalSourceConfig, tagSlotMapping }
}
const now = new Date()
const connectorId = generateId()
const nextSyncAt =
syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null
await db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`)
const activeKb = await tx
.select({ id: knowledgeBase.id })
.from(knowledgeBase)
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
.limit(1)
if (activeKb.length === 0) {
throw new Error('Knowledge base not found')
}
for (const [semanticId, slot] of Object.entries(newTagSlots)) {
const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)!
await createTagDefinition(
{
knowledgeBaseId,
tagSlot: slot,
displayName: td.displayName,
fieldType: td.fieldType,
},
requestId,
tx
)
}
await tx.insert(knowledgeConnector).values({
id: connectorId,
knowledgeBaseId,
connectorType,
credentialId: resolvedCredentialId,
encryptedApiKey: resolvedEncryptedApiKey,
sourceConfig: finalSourceConfig,
syncIntervalMinutes,
status: 'active',
nextSyncAt,
createdAt: now,
updatedAt: now,
})
})
logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`)
const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? ''
captureServerEvent(
auth.userId,
'knowledge_base_connector_added',
{
knowledge_base_id: knowledgeBaseId,
workspace_id: kbWorkspaceId,
connector_type: connectorType,
sync_interval_minutes: syncIntervalMinutes,
},
{
groups: kbWorkspaceId ? { workspace: kbWorkspaceId } : undefined,
setOnce: { first_connector_added_at: new Date().toISOString() },
}
)
recordAudit({
workspaceId: writeCheck.knowledgeBase.workspaceId,
actorId: auth.userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.CONNECTOR_CREATED,
resourceType: AuditResourceType.CONNECTOR,
resourceId: connectorId,
resourceName: connectorType,
description: `Created ${connectorType} connector for knowledge base "${writeCheck.knowledgeBase.name}"`,
metadata: {
knowledgeBaseId,
knowledgeBaseName: writeCheck.knowledgeBase.name,
connectorType,
syncIntervalMinutes,
authMode: connectorConfig.auth.mode,
},
request,
})
dispatchSync(connectorId, { requestId }).catch((error) => {
logger.error(
`[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`,
error
)
})
const created = await db
.select()
.from(knowledgeConnector)
.where(eq(knowledgeConnector.id, connectorId))
.limit(1)
const { encryptedApiKey: _, ...createdData } = created[0]
return NextResponse.json({ success: true, data: createdData }, { status: 201 })
} catch (error) {
if (error instanceof Error && error.message === 'Knowledge base not found') {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
logger.error(`[${requestId}] Error creating connector`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,195 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { updateKnowledgeChunkContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { deleteChunk, updateChunk } from '@/lib/knowledge/chunks/service'
import { checkChunkAccess, checkChunkWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('ChunkByIdAPI')
export const GET = withRouteHandler(
async (
req: NextRequest,
{ params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> }
) => {
const requestId = generateId().slice(0, 8)
const { id: knowledgeBaseId, documentId, chunkId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized chunk access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkChunkAccess(
knowledgeBaseId,
documentId,
chunkId,
session.user.id
)
if (!accessCheck.hasAccess) {
if (accessCheck.notFound) {
logger.warn(
`[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}, Chunk=${chunkId}`
)
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${session.user.id} attempted unauthorized chunk access: ${accessCheck.reason}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
logger.info(
`[${requestId}] Retrieved chunk: ${chunkId} from document ${documentId} in knowledge base ${knowledgeBaseId}`
)
return NextResponse.json({
success: true,
data: accessCheck.chunk,
})
} catch (error) {
logger.error(`[${requestId}] Error fetching chunk`, error)
return NextResponse.json({ error: 'Failed to fetch chunk' }, { status: 500 })
}
}
)
export const PUT = withRouteHandler(
async (
req: NextRequest,
context: { params: Promise<{ id: string; documentId: string; chunkId: string }> }
) => {
const requestId = generateId().slice(0, 8)
const { id: knowledgeBaseId, documentId, chunkId } = await context.params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized chunk update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkChunkWriteAccess(
knowledgeBaseId,
documentId,
chunkId,
session.user.id
)
if (!accessCheck.hasAccess) {
if (accessCheck.notFound) {
logger.warn(
`[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}, Chunk=${chunkId}`
)
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${session.user.id} attempted unauthorized chunk update: ${accessCheck.reason}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (accessCheck.document?.connectorId) {
logger.warn(
`[${requestId}] User ${session.user.id} attempted to update chunk on connector-synced document: Doc=${documentId}`
)
return NextResponse.json(
{ error: 'Chunks from connector-synced documents are read-only' },
{ status: 403 }
)
}
const parsed = await parseRequest(updateKnowledgeChunkContract, req, context)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
const updatedChunk = await updateChunk(
chunkId,
validatedData,
requestId,
accessCheck.knowledgeBase?.workspaceId
)
logger.info(
`[${requestId}] Chunk updated: ${chunkId} in document ${documentId} in knowledge base ${knowledgeBaseId}`
)
return NextResponse.json({
success: true,
data: updatedChunk,
})
} catch (error) {
logger.error(`[${requestId}] Error updating chunk`, error)
return NextResponse.json({ error: 'Failed to update chunk' }, { status: 500 })
}
}
)
export const DELETE = withRouteHandler(
async (
req: NextRequest,
{ params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> }
) => {
const requestId = generateId().slice(0, 8)
const { id: knowledgeBaseId, documentId, chunkId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized chunk delete attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkChunkWriteAccess(
knowledgeBaseId,
documentId,
chunkId,
session.user.id
)
if (!accessCheck.hasAccess) {
if (accessCheck.notFound) {
logger.warn(
`[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}, Chunk=${chunkId}`
)
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${session.user.id} attempted unauthorized chunk deletion: ${accessCheck.reason}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (accessCheck.document?.connectorId) {
logger.warn(
`[${requestId}] User ${session.user.id} attempted to delete chunk on connector-synced document: Doc=${documentId}`
)
return NextResponse.json(
{ error: 'Chunks from connector-synced documents are read-only' },
{ status: 403 }
)
}
await deleteChunk(chunkId, documentId, requestId)
logger.info(
`[${requestId}] Chunk deleted: ${chunkId} from document ${documentId} in knowledge base ${knowledgeBaseId}`
)
return NextResponse.json({
success: true,
data: { message: 'Chunk deleted successfully' },
})
} catch (error) {
logger.error(`[${requestId}] Error deleting chunk`, error)
return NextResponse.json({ error: 'Failed to delete chunk' }, { status: 500 })
}
}
)
@@ -0,0 +1,334 @@
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import {
bulkKnowledgeChunksContract,
createChunkBodySchema,
listKnowledgeChunksQuerySchema,
} from '@/lib/api/contracts/knowledge'
import { isZodError, parseJsonBody, parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { batchChunkOperation, createChunk, queryChunks } from '@/lib/knowledge/chunks/service'
import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils'
import { calculateCost } from '@/providers/utils'
const logger = createLogger('DocumentChunksAPI')
export const GET = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, documentId } = await params
try {
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized chunks access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId)
if (!accessCheck.hasAccess) {
if (accessCheck.notFound) {
logger.warn(
`[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}`
)
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${userId} attempted unauthorized chunks access: ${accessCheck.reason}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const doc = accessCheck.document
if (!doc) {
logger.warn(
`[${requestId}] Document data not available: KB=${knowledgeBaseId}, Doc=${documentId}`
)
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
if (doc.processingStatus !== 'completed') {
logger.warn(
`[${requestId}] Document ${documentId} is not ready for chunk access (status: ${doc.processingStatus})`
)
return NextResponse.json(
{
error: 'Document is not ready for access',
details: `Document status: ${doc.processingStatus}`,
retryAfter: doc.processingStatus === 'processing' ? 5 : null,
},
{ status: 400 }
)
}
const { searchParams } = new URL(req.url)
const queryResult = listKnowledgeChunksQuerySchema.safeParse({
search: searchParams.get('search') || undefined,
enabled: searchParams.get('enabled') || undefined,
limit: searchParams.get('limit') || undefined,
offset: searchParams.get('offset') || undefined,
sortBy: searchParams.get('sortBy') || undefined,
sortOrder: searchParams.get('sortOrder') || undefined,
})
if (!queryResult.success) {
return NextResponse.json(
{ error: 'Invalid query parameters', details: queryResult.error.issues },
{ status: 400 }
)
}
const result = await queryChunks(documentId, queryResult.data, requestId)
return NextResponse.json({
success: true,
data: result.chunks,
pagination: result.pagination,
})
} catch (error) {
logger.error(`[${requestId}] Error fetching chunks`, error)
return NextResponse.json({ error: 'Failed to fetch chunks' }, { status: 500 })
}
}
)
export const POST = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, documentId } = await params
try {
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const parsedBody = await parseJsonBody(req)
if (!parsedBody.success) return parsedBody.response
const { workflowId, ...searchParams } = parsedBody.data as Record<string, unknown>
if (workflowId) {
if (typeof workflowId !== 'string') {
return NextResponse.json({ error: 'workflowId must be a string' }, { status: 400 })
}
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'write',
})
if (!authorization.allowed) {
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
{ status: authorization.status }
)
}
}
const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId)
if (!accessCheck.hasAccess) {
if (accessCheck.notFound) {
logger.warn(
`[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}`
)
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${userId} attempted unauthorized chunk creation: ${accessCheck.reason}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const doc = accessCheck.document
if (!doc) {
logger.warn(
`[${requestId}] Document data not available: KB=${knowledgeBaseId}, Doc=${documentId}`
)
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
if (doc.connectorId) {
logger.warn(
`[${requestId}] User ${userId} attempted to create chunk on connector-synced document: Doc=${documentId}`
)
return NextResponse.json(
{ error: 'Chunks from connector-synced documents are read-only' },
{ status: 403 }
)
}
if (doc.processingStatus === 'failed') {
logger.warn(`[${requestId}] Document ${documentId} is in failed state, cannot add chunks`)
return NextResponse.json({ error: 'Cannot add chunks to failed document' }, { status: 400 })
}
try {
const validatedData = createChunkBodySchema.parse(searchParams)
const docTags = {
tag1: doc.tag1 ?? null,
tag2: doc.tag2 ?? null,
tag3: doc.tag3 ?? null,
tag4: doc.tag4 ?? null,
tag5: doc.tag5 ?? null,
tag6: doc.tag6 ?? null,
tag7: doc.tag7 ?? null,
number1: doc.number1 ?? null,
number2: doc.number2 ?? null,
number3: doc.number3 ?? null,
number4: doc.number4 ?? null,
number5: doc.number5 ?? null,
date1: doc.date1 ?? null,
date2: doc.date2 ?? null,
boolean1: doc.boolean1 ?? null,
boolean2: doc.boolean2 ?? null,
boolean3: doc.boolean3 ?? null,
}
const newChunk = await createChunk(
knowledgeBaseId,
documentId,
docTags,
validatedData,
requestId,
accessCheck.knowledgeBase?.workspaceId
)
let cost = null
try {
cost = calculateCost(
accessCheck.knowledgeBase.embeddingModel,
newChunk.tokenCount,
0,
false
)
} catch (error) {
logger.warn(`[${requestId}] Failed to calculate cost for chunk upload`, {
error: getErrorMessage(error, 'Unknown error'),
})
}
return NextResponse.json({
success: true,
data: {
...newChunk,
documentId,
documentName: doc.filename,
...(cost
? {
cost: {
input: cost.input,
output: cost.output,
total: cost.total,
tokens: {
prompt: newChunk.tokenCount,
completion: 0,
total: newChunk.tokenCount,
},
model: accessCheck.knowledgeBase.embeddingModel,
pricing: cost.pricing,
},
}
: {}),
},
})
} catch (validationError) {
if (isZodError(validationError)) {
logger.warn(`[${requestId}] Invalid chunk creation data`, {
errors: validationError.issues,
})
return NextResponse.json(
{ error: 'Invalid request data', details: validationError.issues },
{ status: 400 }
)
}
throw validationError
}
} catch (error) {
logger.error(`[${requestId}] Error creating chunk`, error)
return NextResponse.json({ error: 'Failed to create chunk' }, { status: 500 })
}
}
)
export const PATCH = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, documentId } = await params
try {
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized batch chunk operation attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId)
if (!accessCheck.hasAccess) {
if (accessCheck.notFound) {
logger.warn(
`[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}`
)
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${userId} attempted unauthorized batch chunk operation: ${accessCheck.reason}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (accessCheck.document?.connectorId) {
logger.warn(
`[${requestId}] User ${userId} attempted batch chunk operation on connector-synced document: Doc=${documentId}`
)
return NextResponse.json(
{ error: 'Chunks from connector-synced documents are read-only' },
{ status: 403 }
)
}
const parsed = await parseRequest(
bulkKnowledgeChunksContract,
req,
{ params },
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid batch operation data`, { errors: error.issues })
return NextResponse.json(
{ error: 'Invalid request data', details: error.issues },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
const { operation, chunkIds } = validatedData
const result = await batchChunkOperation(documentId, operation, chunkIds, requestId)
return NextResponse.json({
success: true,
data: {
operation,
successCount: result.processed,
errorCount: result.errors.length,
processed: result.processed,
errors: result.errors,
},
})
} catch (error) {
logger.error(`[${requestId}] Error in batch chunk operation`, error)
return NextResponse.json({ error: 'Failed to perform batch operation' }, { status: 500 })
}
}
)
@@ -0,0 +1,560 @@
/**
* Tests for document by ID API route
*
* @vitest-environment node
*/
import { auditMock, authMockFns, createMockRequest, knowledgeApiUtilsMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDbChain } = vi.hoisted(() => {
const mockDbChain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
delete: vi.fn().mockReturnThis(),
transaction: vi.fn(),
}
return { mockDbChain }
})
vi.mock('@sim/db', () => ({
db: mockDbChain,
}))
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
vi.mock('@/lib/knowledge/documents/service', () => ({
updateDocument: vi.fn(),
deleteDocument: vi.fn(),
markDocumentAsFailedTimeout: vi.fn(),
retryDocumentProcessing: vi.fn(),
processDocumentAsync: vi.fn(),
}))
vi.mock('@sim/audit', () => auditMock)
import {
deleteDocument,
markDocumentAsFailedTimeout,
retryDocumentProcessing,
updateDocument,
} from '@/lib/knowledge/documents/service'
import { DELETE, GET, PUT } from '@/app/api/knowledge/[id]/documents/[documentId]/route'
import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils'
describe('Document By ID API Route', () => {
const mockDocument = {
id: 'doc-123',
knowledgeBaseId: 'kb-123',
filename: 'test-document.pdf',
fileUrl: 'https://example.com/test-document.pdf',
fileSize: 1024,
mimeType: 'application/pdf',
chunkCount: 5,
tokenCount: 100,
characterCount: 500,
processingStatus: 'completed' as const,
processingStartedAt: new Date('2023-01-01T10:00:00Z'),
processingCompletedAt: new Date('2023-01-01T10:05:00Z'),
processingError: null,
enabled: true,
uploadedAt: new Date('2023-01-01T09:00:00Z'),
tag1: null,
tag2: null,
tag3: null,
tag4: null,
tag5: null,
tag6: null,
tag7: null,
number1: null,
number2: null,
number3: null,
number4: null,
number5: null,
date1: null,
date2: null,
boolean1: null,
boolean2: null,
boolean3: null,
deletedAt: null,
}
const resetMocks = () => {
vi.clearAllMocks()
Object.values(mockDbChain).forEach((fn) => {
if (typeof fn === 'function') {
fn.mockClear().mockReset()
if (fn !== mockDbChain.transaction) {
fn.mockReturnThis()
}
}
})
}
beforeEach(() => {
resetMocks()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
})
})
afterEach(() => {
vi.clearAllMocks()
})
describe('GET /api/knowledge/[id]/documents/[documentId]', () => {
const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' })
it('should retrieve document successfully for authenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentAccess).mockResolvedValue({
hasAccess: true,
document: mockDocument,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.id).toBe('doc-123')
expect(data.data.filename).toBe('test-document.pdf')
expect(vi.mocked(checkDocumentAccess)).toHaveBeenCalledWith('kb-123', 'doc-123', 'user-123')
})
it('should return unauthorized for unauthenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should return not found for non-existent document', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentAccess).mockResolvedValue({
hasAccess: false,
notFound: true,
reason: 'Document not found',
})
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('Document not found')
})
it('should return unauthorized for document without access', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentAccess).mockResolvedValue({
hasAccess: false,
reason: 'Access denied',
})
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should handle database errors', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentAccess).mockRejectedValue(new Error('Database error'))
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Failed to fetch document')
})
})
describe('PUT /api/knowledge/[id]/documents/[documentId] - Regular Updates', () => {
const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' })
const validUpdateData = {
filename: 'updated-document.pdf',
enabled: false,
chunkCount: 10,
tokenCount: 200,
}
it('should update document successfully', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentWriteAccess).mockResolvedValue({
hasAccess: true,
document: mockDocument,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
const updatedDocument = {
...mockDocument,
...validUpdateData,
deletedAt: null,
}
vi.mocked(updateDocument).mockResolvedValue(updatedDocument)
const req = createMockRequest('PUT', validUpdateData)
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.filename).toBe('updated-document.pdf')
expect(data.data.enabled).toBe(false)
expect(vi.mocked(updateDocument)).toHaveBeenCalledWith(
'doc-123',
validUpdateData,
expect.any(String)
)
})
it('should validate update data', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentWriteAccess).mockResolvedValue({
hasAccess: true,
document: mockDocument,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
const invalidData = {
filename: '', // Invalid: empty filename
chunkCount: -1, // Invalid: negative count
processingStatus: 'invalid', // Invalid: not in enum
}
const req = createMockRequest('PUT', invalidData)
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Invalid request data')
expect(data.details).toBeDefined()
})
})
describe('PUT /api/knowledge/[id]/documents/[documentId] - Mark Failed Due to Timeout', () => {
const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' })
it('should mark document as failed due to timeout successfully', async () => {
const processingDocument = {
...mockDocument,
processingStatus: 'processing',
processingStartedAt: new Date(Date.now() - 200000), // 200 seconds ago
}
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentWriteAccess).mockResolvedValue({
hasAccess: true,
document: processingDocument,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(markDocumentAsFailedTimeout).mockResolvedValue({
success: true,
processingDuration: 200000,
})
const req = createMockRequest('PUT', { markFailedDueToTimeout: true })
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.documentId).toBe('doc-123')
expect(data.data.status).toBe('failed')
expect(data.data.message).toBe('Document marked as failed due to timeout')
expect(vi.mocked(markDocumentAsFailedTimeout)).toHaveBeenCalledWith(
'doc-123',
processingDocument.processingStartedAt,
expect.any(String)
)
})
it('should reject marking failed for non-processing document', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentWriteAccess).mockResolvedValue({
hasAccess: true,
document: { ...mockDocument, processingStatus: 'completed' },
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
const req = createMockRequest('PUT', { markFailedDueToTimeout: true })
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('Document is not in processing state')
})
it('should reject marking failed for recently started processing', async () => {
const recentProcessingDocument = {
...mockDocument,
processingStatus: 'processing',
processingStartedAt: new Date(Date.now() - 60000), // 60 seconds ago
}
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentWriteAccess).mockResolvedValue({
hasAccess: true,
document: recentProcessingDocument,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(markDocumentAsFailedTimeout).mockRejectedValue(
new Error('Document has not been processing long enough to be considered dead')
)
const req = createMockRequest('PUT', { markFailedDueToTimeout: true })
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('Document has not been processing long enough')
})
})
describe('PUT /api/knowledge/[id]/documents/[documentId] - Retry Processing', () => {
const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' })
it('should retry processing successfully', async () => {
const failedDocument = {
...mockDocument,
processingStatus: 'failed',
processingError: 'Previous processing failed',
}
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentWriteAccess).mockResolvedValue({
hasAccess: true,
document: failedDocument,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(retryDocumentProcessing).mockResolvedValue({
success: true,
status: 'pending',
message: 'Document retry processing started',
})
const req = createMockRequest('PUT', { retryProcessing: true })
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.status).toBe('pending')
expect(data.data.message).toBe('Document retry processing started')
expect(vi.mocked(retryDocumentProcessing)).toHaveBeenCalledWith(
'kb-123',
'doc-123',
{
filename: failedDocument.filename,
fileUrl: failedDocument.fileUrl,
fileSize: failedDocument.fileSize,
mimeType: failedDocument.mimeType,
},
expect.any(String)
)
})
it('should reject retry for non-failed document', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentWriteAccess).mockResolvedValue({
hasAccess: true,
document: { ...mockDocument, processingStatus: 'completed' },
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
const req = createMockRequest('PUT', { retryProcessing: true })
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Document is not in failed state')
})
})
describe('PUT /api/knowledge/[id]/documents/[documentId] - Authentication & Authorization', () => {
const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' })
const validUpdateData = { filename: 'updated-document.pdf' }
it('should return unauthorized for unauthenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = createMockRequest('PUT', validUpdateData)
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should return not found for non-existent document', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentWriteAccess).mockResolvedValue({
hasAccess: false,
notFound: true,
reason: 'Document not found',
})
const req = createMockRequest('PUT', validUpdateData)
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('Document not found')
})
it('should handle database errors during update', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentWriteAccess).mockResolvedValue({
hasAccess: true,
document: mockDocument,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(updateDocument).mockRejectedValue(new Error('Database error'))
const req = createMockRequest('PUT', validUpdateData)
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Failed to update document')
})
})
describe('DELETE /api/knowledge/[id]/documents/[documentId]', () => {
const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' })
it('should delete document successfully', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentWriteAccess).mockResolvedValue({
hasAccess: true,
document: mockDocument,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(deleteDocument).mockResolvedValue({
success: true,
message: 'Document deleted successfully',
})
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.message).toBe('Document deleted successfully')
expect(vi.mocked(deleteDocument)).toHaveBeenCalledWith('doc-123', expect.any(String))
})
it('should return unauthorized for unauthenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should return not found for non-existent document', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentWriteAccess).mockResolvedValue({
hasAccess: false,
notFound: true,
reason: 'Document not found',
})
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('Document not found')
})
it('should return unauthorized for document without access', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentWriteAccess).mockResolvedValue({
hasAccess: false,
reason: 'Access denied',
})
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should handle database errors during deletion', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkDocumentWriteAccess).mockResolvedValue({
hasAccess: true,
document: mockDocument,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(deleteDocument).mockRejectedValue(new Error('Database error'))
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Failed to delete document')
})
})
})
@@ -0,0 +1,286 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { updateKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
deleteDocument,
markDocumentAsFailedTimeout,
retryDocumentProcessing,
updateDocument,
} from '@/lib/knowledge/documents/service'
import { captureServerEvent } from '@/lib/posthog/server'
import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('DocumentByIdAPI')
export const GET = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, documentId } = await params
try {
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized document access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId)
if (!accessCheck.hasAccess) {
if (accessCheck.notFound) {
logger.warn(
`[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}`
)
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${userId} attempted unauthorized document access: ${accessCheck.reason}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
logger.info(
`[${requestId}] Retrieved document: ${documentId} from knowledge base ${knowledgeBaseId}`
)
return NextResponse.json({
success: true,
data: accessCheck.document,
})
} catch (error) {
logger.error(`[${requestId}] Error fetching document`, error)
return NextResponse.json({ error: 'Failed to fetch document' }, { status: 500 })
}
}
)
export const PUT = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, documentId } = await params
try {
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized document update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId)
if (!accessCheck.hasAccess) {
if (accessCheck.notFound) {
logger.warn(
`[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}`
)
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${userId} attempted unauthorized document update: ${accessCheck.reason}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(
updateKnowledgeDocumentContract,
req,
{ params },
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid document update data`, { errors: error.issues })
return NextResponse.json(
{ error: 'Invalid request data', details: error.issues },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
const updateData: any = {}
if (validatedData.markFailedDueToTimeout) {
const doc = accessCheck.document
if (doc.processingStatus !== 'processing') {
return NextResponse.json(
{ error: `Document is not in processing state (current: ${doc.processingStatus})` },
{ status: 400 }
)
}
if (!doc.processingStartedAt) {
return NextResponse.json(
{ error: 'Document has no processing start time' },
{ status: 400 }
)
}
try {
await markDocumentAsFailedTimeout(documentId, doc.processingStartedAt, requestId)
return NextResponse.json({
success: true,
data: {
documentId,
status: 'failed',
message: 'Document marked as failed due to timeout',
},
})
} catch (error) {
if (error instanceof Error) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
throw error
}
} else if (validatedData.retryProcessing) {
const doc = accessCheck.document
if (doc.processingStatus !== 'failed') {
return NextResponse.json({ error: 'Document is not in failed state' }, { status: 400 })
}
const docData = {
filename: doc.filename,
fileUrl: doc.fileUrl,
fileSize: doc.fileSize,
mimeType: doc.mimeType,
}
const result = await retryDocumentProcessing(
knowledgeBaseId,
documentId,
docData,
requestId
)
return NextResponse.json({
success: true,
data: {
documentId,
status: result.status,
message: result.message,
},
})
} else {
const updatedDocument = await updateDocument(documentId, validatedData, requestId)
logger.info(
`[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}`
)
recordAudit({
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.DOCUMENT_UPDATED,
resourceType: AuditResourceType.DOCUMENT,
resourceId: documentId,
resourceName: validatedData.filename ?? accessCheck.document?.filename,
description: `Updated document "${validatedData.filename ?? accessCheck.document?.filename}" in knowledge base "${knowledgeBaseId}"`,
metadata: {
knowledgeBaseId,
knowledgeBaseName: accessCheck.knowledgeBase?.name,
fileName: validatedData.filename ?? accessCheck.document?.filename,
updatedFields: Object.keys(validatedData).filter(
(k) => validatedData[k as keyof typeof validatedData] !== undefined
),
...(validatedData.enabled !== undefined && { enabled: validatedData.enabled }),
},
request: req,
})
return NextResponse.json({
success: true,
data: updatedDocument,
})
}
} catch (error) {
logger.error(`[${requestId}] Error updating document ${documentId}`, error)
return NextResponse.json({ error: 'Failed to update document' }, { status: 500 })
}
}
)
export const DELETE = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => {
const requestId = generateRequestId()
const { id: knowledgeBaseId, documentId } = await params
try {
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized document delete attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId)
if (!accessCheck.hasAccess) {
if (accessCheck.notFound) {
logger.warn(
`[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}`
)
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${userId} attempted unauthorized document deletion: ${accessCheck.reason}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const result = await deleteDocument(documentId, requestId)
logger.info(
`[${requestId}] Document deleted: ${documentId} from knowledge base ${knowledgeBaseId}`
)
recordAudit({
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.DOCUMENT_DELETED,
resourceType: AuditResourceType.DOCUMENT,
resourceId: documentId,
resourceName: accessCheck.document?.filename,
description: `Deleted document "${accessCheck.document?.filename}" from knowledge base "${knowledgeBaseId}"`,
metadata: {
knowledgeBaseId,
knowledgeBaseName: accessCheck.knowledgeBase?.name,
fileName: accessCheck.document?.filename,
fileSize: accessCheck.document?.fileSize,
mimeType: accessCheck.document?.mimeType,
},
request: req,
})
const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId ?? ''
captureServerEvent(
userId,
'knowledge_base_document_deleted',
{ knowledge_base_id: knowledgeBaseId, workspace_id: kbWorkspaceId },
kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
)
return NextResponse.json({
success: true,
data: result,
})
} catch (error) {
logger.error(`[${requestId}] Error deleting document`, error)
return NextResponse.json({ error: 'Failed to delete document' }, { status: 500 })
}
}
)
@@ -0,0 +1,205 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { saveDocumentTagDefinitionsContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants'
import {
cleanupUnusedTagDefinitions,
createOrUpdateTagDefinitionsBulk,
deleteAllTagDefinitions,
getDocumentTagDefinitions,
} from '@/lib/knowledge/tags/service'
import type { BulkTagDefinitionsData } from '@/lib/knowledge/tags/types'
import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('DocumentTagDefinitionsAPI')
// GET /api/knowledge/[id]/documents/[documentId]/tag-definitions - Get tag definitions for a document
export const GET = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => {
const requestId = generateId().slice(0, 8)
const { id: knowledgeBaseId, documentId } = await params
try {
logger.info(`[${requestId}] Getting tag definitions for document ${documentId}`)
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify document exists and belongs to the knowledge base
const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, session.user.id)
if (!accessCheck.hasAccess) {
if (accessCheck.notFound) {
logger.warn(
`[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}`
)
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${session.user.id} attempted unauthorized document access: ${accessCheck.reason}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const tagDefinitions = await getDocumentTagDefinitions(knowledgeBaseId)
logger.info(`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions`)
return NextResponse.json({
success: true,
data: tagDefinitions,
})
} catch (error) {
logger.error(`[${requestId}] Error getting tag definitions`, error)
return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 })
}
}
)
// POST /api/knowledge/[id]/documents/[documentId]/tag-definitions - Create/update tag definitions
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string; documentId: string }> }) => {
const requestId = generateId().slice(0, 8)
const { id: knowledgeBaseId, documentId } = await context.params
try {
logger.info(`[${requestId}] Creating/updating tag definitions for document ${documentId}`)
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify document exists and user has write access
const accessCheck = await checkDocumentWriteAccess(
knowledgeBaseId,
documentId,
session.user.id
)
if (!accessCheck.hasAccess) {
if (accessCheck.notFound) {
logger.warn(
`[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}`
)
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${session.user.id} attempted unauthorized document write access: ${accessCheck.reason}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(saveDocumentTagDefinitionsContract, req, context)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
for (const def of validatedData.definitions) {
/**
* Defense-in-depth runtime check: the contract types `fieldType` as a plain
* string because tightening to the field-type enum cascades into UI form
* state types. Cast here to allow `includes` to accept the wider input.
*/
if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(def.fieldType)) {
return NextResponse.json(
{ error: 'Invalid request data', details: `Unsupported field type: ${def.fieldType}` },
{ status: 400 }
)
}
}
const bulkData: BulkTagDefinitionsData = {
definitions: validatedData.definitions.map((def) => ({
tagSlot: def.tagSlot,
displayName: def.displayName,
fieldType: def.fieldType,
originalDisplayName: def._originalDisplayName,
})),
}
const result = await createOrUpdateTagDefinitionsBulk(knowledgeBaseId, bulkData, requestId)
return NextResponse.json({
success: true,
data: {
created: result.created,
updated: result.updated,
errors: result.errors,
},
})
} catch (error) {
logger.error(`[${requestId}] Error creating/updating tag definitions`, error)
return NextResponse.json(
{ error: 'Failed to create/update tag definitions' },
{ status: 500 }
)
}
}
)
// DELETE /api/knowledge/[id]/documents/[documentId]/tag-definitions - Delete all tag definitions for a document
export const DELETE = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => {
const requestId = generateId().slice(0, 8)
const { id: knowledgeBaseId, documentId } = await params
const { searchParams } = new URL(req.url)
const action = searchParams.get('action') // 'cleanup' or 'all'
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify document exists and user has write access
const accessCheck = await checkDocumentWriteAccess(
knowledgeBaseId,
documentId,
session.user.id
)
if (!accessCheck.hasAccess) {
if (accessCheck.notFound) {
logger.warn(
`[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}`
)
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${session.user.id} attempted unauthorized document write access: ${accessCheck.reason}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (action === 'cleanup') {
// Just run cleanup
logger.info(`[${requestId}] Running cleanup for KB ${knowledgeBaseId}`)
const cleanedUpCount = await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId)
return NextResponse.json({
success: true,
data: { cleanedUp: cleanedUpCount },
})
}
// Delete all tag definitions (original behavior)
logger.info(`[${requestId}] Deleting all tag definitions for KB ${knowledgeBaseId}`)
const deletedCount = await deleteAllTagDefinitions(knowledgeBaseId, requestId)
return NextResponse.json({
success: true,
message: 'Tag definitions deleted successfully',
data: { deleted: deletedCount },
})
} catch (error) {
logger.error(`[${requestId}] Error with tag definitions operation`, error)
return NextResponse.json({ error: 'Failed to process tag definitions' }, { status: 500 })
}
}
)
@@ -0,0 +1,598 @@
/**
* Tests for knowledge base documents API route
*
* @vitest-environment node
*/
import { auditMock, authMockFns, createMockRequest, knowledgeApiUtilsMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDbChain } = vi.hoisted(() => {
const mockDbChain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
transaction: vi.fn(),
}
return { mockDbChain }
})
vi.mock('@sim/db', () => ({
db: mockDbChain,
}))
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
vi.mock('@/lib/knowledge/documents/service', () => ({
getDocuments: vi.fn(),
createSingleDocument: vi.fn(),
createDocumentRecords: vi.fn(),
processDocumentsWithQueue: vi.fn(),
getProcessingConfig: vi.fn(),
bulkDocumentOperation: vi.fn(),
updateDocument: vi.fn(),
deleteDocument: vi.fn(),
markDocumentAsFailedTimeout: vi.fn(),
retryDocumentProcessing: vi.fn(),
KnowledgeBaseFileOwnershipError: class KnowledgeBaseFileOwnershipError extends Error {},
}))
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
checkActorUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }),
}))
vi.mock('@sim/audit', () => auditMock)
import {
createDocumentRecords,
createSingleDocument,
getDocuments,
getProcessingConfig,
processDocumentsWithQueue,
} from '@/lib/knowledge/documents/service'
import { GET, POST } from '@/app/api/knowledge/[id]/documents/route'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
describe('Knowledge Base Documents API Route', () => {
const mockDocument = {
id: 'doc-123',
knowledgeBaseId: 'kb-123',
filename: 'test-document.pdf',
fileUrl: 'https://example.com/test-document.pdf',
fileSize: 1024,
mimeType: 'application/pdf',
chunkCount: 5,
tokenCount: 100,
characterCount: 500,
processingStatus: 'completed' as const,
processingStartedAt: new Date(),
processingCompletedAt: new Date(),
processingError: null,
enabled: true,
uploadedAt: new Date(),
tag1: null,
tag2: null,
tag3: null,
tag4: null,
tag5: null,
tag6: null,
tag7: null,
number1: null,
number2: null,
number3: null,
number4: null,
number5: null,
date1: null,
date2: null,
boolean1: null,
boolean2: null,
boolean3: null,
deletedAt: null,
}
const resetMocks = () => {
vi.clearAllMocks()
Object.values(mockDbChain).forEach((fn) => {
if (typeof fn === 'function') {
fn.mockClear().mockReset()
if (fn !== mockDbChain.transaction) {
fn.mockReturnThis()
}
}
})
}
beforeEach(() => {
resetMocks()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
})
})
afterEach(() => {
vi.clearAllMocks()
})
describe('GET /api/knowledge/[id]/documents', () => {
const mockParams = Promise.resolve({ id: 'kb-123' })
it('should retrieve documents successfully for authenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(getDocuments).mockResolvedValue({
documents: [mockDocument],
pagination: {
total: 1,
limit: 50,
offset: 0,
hasMore: false,
},
})
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.documents).toHaveLength(1)
expect(data.data.documents[0].id).toBe('doc-123')
expect(vi.mocked(checkKnowledgeBaseAccess)).toHaveBeenCalledWith('kb-123', 'user-123')
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
'kb-123',
{
enabledFilter: undefined,
search: undefined,
limit: 50,
offset: 0,
},
expect.any(String)
)
})
it('should return documents with default filter', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(getDocuments).mockResolvedValue({
documents: [mockDocument],
pagination: {
total: 1,
limit: 50,
offset: 0,
hasMore: false,
},
})
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(200)
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
'kb-123',
{
enabledFilter: undefined,
search: undefined,
limit: 50,
offset: 0,
},
expect.any(String)
)
})
it('should filter documents by enabled status when requested', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(getDocuments).mockResolvedValue({
documents: [mockDocument],
pagination: {
total: 1,
limit: 50,
offset: 0,
hasMore: false,
},
})
const url = 'http://localhost:3000/api/knowledge/kb-123/documents?enabledFilter=disabled'
const req = new Request(url, { method: 'GET' }) as any
const response = await GET(req, { params: mockParams })
expect(response.status).toBe(200)
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
'kb-123',
{
enabledFilter: 'disabled',
search: undefined,
limit: 50,
offset: 0,
},
expect.any(String)
)
})
it('should return unauthorized for unauthenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should return not found for non-existent knowledge base', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({
hasAccess: false,
notFound: true,
})
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('Knowledge base not found')
})
it('should return unauthorized for knowledge base without access', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({ hasAccess: false })
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should handle database errors', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValue({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(getDocuments).mockRejectedValue(new Error('Database error'))
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Failed to fetch documents')
})
})
describe('POST /api/knowledge/[id]/documents - Single Document', () => {
const mockParams = Promise.resolve({ id: 'kb-123' })
const validDocumentData = {
filename: 'test-document.pdf',
fileUrl: 'https://example.com/test-document.pdf',
fileSize: 1024,
mimeType: 'application/pdf',
}
it('should create single document successfully', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
const createdDocument = {
id: 'doc-123',
knowledgeBaseId: 'kb-123',
filename: validDocumentData.filename,
fileUrl: validDocumentData.fileUrl,
fileSize: validDocumentData.fileSize,
mimeType: validDocumentData.mimeType,
chunkCount: 0,
tokenCount: 0,
characterCount: 0,
enabled: true,
uploadedAt: new Date(),
tag1: null,
tag2: null,
tag3: null,
tag4: null,
tag5: null,
tag6: null,
tag7: null,
}
vi.mocked(createSingleDocument).mockResolvedValue(createdDocument)
const req = createMockRequest('POST', validDocumentData)
const response = await POST(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.filename).toBe(validDocumentData.filename)
expect(data.data.fileUrl).toBe(validDocumentData.fileUrl)
expect(vi.mocked(createSingleDocument)).toHaveBeenCalledWith(
validDocumentData,
'kb-123',
expect.any(String),
'user-123'
)
})
it('should validate single document data', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
const invalidData = {
filename: '', // Invalid: empty filename
fileUrl: 'invalid-url', // Invalid: not a valid URL
fileSize: 0, // Invalid: size must be > 0
mimeType: '', // Invalid: empty mime type
}
const req = createMockRequest('POST', invalidData)
const response = await POST(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Invalid request data')
expect(data.details).toBeDefined()
})
})
describe('POST /api/knowledge/[id]/documents - Bulk Documents', () => {
const mockParams = Promise.resolve({ id: 'kb-123' })
const validBulkData = {
bulk: true,
documents: [
{
filename: 'doc1.pdf',
fileUrl: 'https://example.com/doc1.pdf',
fileSize: 1024,
mimeType: 'application/pdf',
},
{
filename: 'doc2.pdf',
fileUrl: 'https://example.com/doc2.pdf',
fileSize: 2048,
mimeType: 'application/pdf',
},
],
processingOptions: {
recipe: 'default',
lang: 'en',
},
}
it('should create bulk documents successfully', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
const createdDocuments = [
{
documentId: 'doc-1',
filename: 'doc1.pdf',
fileUrl: 'https://example.com/doc1.pdf',
fileSize: 1024,
mimeType: 'application/pdf',
},
{
documentId: 'doc-2',
filename: 'doc2.pdf',
fileUrl: 'https://example.com/doc2.pdf',
fileSize: 2048,
mimeType: 'application/pdf',
},
]
vi.mocked(createDocumentRecords).mockResolvedValue(createdDocuments)
vi.mocked(processDocumentsWithQueue).mockResolvedValue(undefined)
vi.mocked(getProcessingConfig).mockReturnValue({
maxConcurrentDocuments: 8,
batchSize: 20,
delayBetweenBatches: 100,
delayBetweenDocuments: 0,
})
const req = createMockRequest('POST', validBulkData)
const response = await POST(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.total).toBe(2)
expect(data.data.documentsCreated).toHaveLength(2)
expect(data.data.processingMethod).toBe('background')
expect(vi.mocked(createDocumentRecords)).toHaveBeenCalledWith(
validBulkData.documents,
'kb-123',
expect.any(String),
'user-123'
)
expect(vi.mocked(processDocumentsWithQueue)).toHaveBeenCalled()
})
it('should validate bulk document data', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
const invalidBulkData = {
bulk: true,
documents: [
{
filename: '', // Invalid: empty filename
fileUrl: 'invalid-url',
fileSize: 0,
mimeType: '',
},
],
processingOptions: {
recipe: 'default',
lang: 'en',
},
}
const req = createMockRequest('POST', invalidBulkData)
const response = await POST(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Invalid request data')
expect(data.details).toBeDefined()
})
it('should handle processing errors gracefully', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
const createdDocuments = [
{
documentId: 'doc-1',
filename: 'doc1.pdf',
fileUrl: 'https://example.com/doc1.pdf',
fileSize: 1024,
mimeType: 'application/pdf',
},
]
vi.mocked(createDocumentRecords).mockResolvedValue(createdDocuments)
vi.mocked(processDocumentsWithQueue).mockResolvedValue(undefined)
vi.mocked(getProcessingConfig).mockReturnValue({
maxConcurrentDocuments: 8,
batchSize: 20,
delayBetweenBatches: 100,
delayBetweenDocuments: 0,
})
const req = createMockRequest('POST', validBulkData)
const response = await POST(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
})
})
describe('POST /api/knowledge/[id]/documents - Authentication & Authorization', () => {
const mockParams = Promise.resolve({ id: 'kb-123' })
const validDocumentData = {
filename: 'test-document.pdf',
fileUrl: 'https://example.com/test-document.pdf',
fileSize: 1024,
mimeType: 'application/pdf',
}
it('should return unauthorized for unauthenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = createMockRequest('POST', validDocumentData)
const response = await POST(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should return not found for non-existent knowledge base', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({
hasAccess: false,
notFound: true,
})
const req = createMockRequest('POST', validDocumentData)
const response = await POST(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('Knowledge base not found')
})
it('should return unauthorized for knowledge base without access', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({ hasAccess: false })
const req = createMockRequest('POST', validDocumentData)
const response = await POST(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should handle database errors during creation', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(createSingleDocument).mockRejectedValue(new Error('Database error'))
const req = createMockRequest('POST', validDocumentData)
const response = await POST(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Database error')
})
})
})
@@ -0,0 +1,438 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import {
bulkKnowledgeDocumentsContract,
createKnowledgeDocumentsContract,
listKnowledgeDocumentsQuerySchema,
parseDocumentTagFiltersParam,
} from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
bulkDocumentOperation,
bulkDocumentOperationByFilter,
createDocumentRecords,
createSingleDocument,
getDocuments,
getProcessingConfig,
KnowledgeBaseFileOwnershipError,
processDocumentsWithQueue,
} from '@/lib/knowledge/documents/service'
import type { TagFilterCondition } from '@/lib/knowledge/documents/tag-filter'
import { captureServerEvent } from '@/lib/posthog/server'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('DocumentsAPI')
export const GET = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateId().slice(0, 8)
const { id: knowledgeBaseId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized documents access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
if ('notFound' in accessCheck && accessCheck.notFound) {
logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`)
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${session.user.id} attempted to access unauthorized knowledge base documents ${knowledgeBaseId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const queryResult = listKnowledgeDocumentsQuerySchema.safeParse(
Object.fromEntries(new URL(req.url).searchParams.entries())
)
if (!queryResult.success) {
return NextResponse.json(
{ error: 'Invalid query parameters', details: queryResult.error.issues },
{ status: 400 }
)
}
const { enabledFilter, search, limit, offset, sortBy, sortOrder, tagFilters } =
queryResult.data
let parsedTagFilters: TagFilterCondition[] | undefined
try {
parsedTagFilters = parseDocumentTagFiltersParam(tagFilters) as
| TagFilterCondition[]
| undefined
} catch {
return NextResponse.json(
{ error: 'tagFilters must be a valid JSON array' },
{ status: 400 }
)
}
const result = await getDocuments(
knowledgeBaseId,
{
enabledFilter: enabledFilter || undefined,
search,
limit,
offset,
...(sortBy && { sortBy }),
...(sortOrder && { sortOrder }),
tagFilters: parsedTagFilters,
},
requestId
)
return NextResponse.json({
success: true,
data: {
documents: result.documents,
pagination: result.pagination,
},
})
} catch (error) {
logger.error(`[${requestId}] Error fetching documents`, error)
return NextResponse.json({ error: 'Failed to fetch documents' }, { status: 500 })
}
}
)
export const POST = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateId().slice(0, 8)
const { id: knowledgeBaseId } = await params
try {
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const parsed = await parseRequest(
createKnowledgeDocumentsContract,
req,
{ params },
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid document creation request`, {
errors: error.issues,
})
return NextResponse.json(
{ error: 'Invalid request data', details: error.issues },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const body = parsed.data.body
const workflowId = body.workflowId
logger.info(`[${requestId}] Knowledge base document creation request`, {
knowledgeBaseId,
workflowId,
hasWorkflowId: !!workflowId,
bulk: body.bulk === true,
})
if (workflowId) {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'write',
})
if (!authorization.allowed) {
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
{ status: authorization.status }
)
}
}
const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId)
if (!accessCheck.hasAccess) {
if ('notFound' in accessCheck && accessCheck.notFound) {
logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`)
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${userId} attempted to create document in unauthorized knowledge base ${knowledgeBaseId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId
// Gate KB indexing (pooled + per-member) before accepting work. Runs even for
// legacy KBs with no workspace — the uploader's pooled/frozen status is still
// enforced (per-member is simply skipped when there's no org workspace). The
// authoritative backstop also runs in processDocumentAsync for non-HTTP paths
// (connector/cron/retry).
const usage = await checkActorUsageLimits(userId, kbWorkspaceId)
if (usage.isExceeded) {
return NextResponse.json(
{
error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.',
},
{ status: 402 }
)
}
if (body.bulk === true) {
const createdDocuments = await createDocumentRecords(
body.documents,
knowledgeBaseId,
requestId,
userId
)
logger.info(
`[${requestId}] Starting controlled async processing of ${createdDocuments.length} documents`
)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.knowledgeBaseDocumentsUploaded({
knowledgeBaseId,
documentsCount: createdDocuments.length,
uploadType: 'bulk',
recipe: body.processingOptions?.recipe,
})
} catch (_e) {
// Silently fail
}
captureServerEvent(
userId,
'knowledge_base_document_uploaded',
{
knowledge_base_id: knowledgeBaseId,
workspace_id: kbWorkspaceId ?? '',
document_count: createdDocuments.length,
upload_type: 'bulk',
},
{
...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}),
setOnce: { first_document_uploaded_at: new Date().toISOString() },
}
)
processDocumentsWithQueue(
createdDocuments,
knowledgeBaseId,
body.processingOptions ?? {},
requestId
).catch((error: unknown) => {
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
})
recordAudit({
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.DOCUMENT_UPLOADED,
resourceType: AuditResourceType.DOCUMENT,
resourceId: knowledgeBaseId,
resourceName: `${createdDocuments.length} document(s)`,
description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`,
metadata: {
knowledgeBaseName: accessCheck.knowledgeBase?.name,
fileCount: createdDocuments.length,
},
request: req,
})
return NextResponse.json({
success: true,
data: {
total: createdDocuments.length,
documentsCreated: createdDocuments.map((doc) => ({
documentId: doc.documentId,
filename: doc.filename,
status: 'pending',
})),
processingMethod: 'background',
processingConfig: {
maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments,
batchSize: getProcessingConfig().batchSize,
totalBatches: Math.ceil(createdDocuments.length / getProcessingConfig().batchSize),
},
},
})
}
const { bulk: _bulk, workflowId: _workflowId, ...singleDocumentData } = body
const newDocument = await createSingleDocument(
singleDocumentData,
knowledgeBaseId,
requestId,
userId
)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.knowledgeBaseDocumentsUploaded({
knowledgeBaseId,
documentsCount: 1,
uploadType: 'single',
mimeType: singleDocumentData.mimeType,
fileSize: singleDocumentData.fileSize,
})
} catch (_e) {
// Silently fail
}
captureServerEvent(
userId,
'knowledge_base_document_uploaded',
{
knowledge_base_id: knowledgeBaseId,
workspace_id: kbWorkspaceId ?? '',
document_count: 1,
upload_type: 'single',
},
{
...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}),
setOnce: { first_document_uploaded_at: new Date().toISOString() },
}
)
recordAudit({
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.DOCUMENT_UPLOADED,
resourceType: AuditResourceType.DOCUMENT,
resourceId: knowledgeBaseId,
resourceName: singleDocumentData.filename,
description: `Uploaded document "${singleDocumentData.filename}" to knowledge base "${knowledgeBaseId}"`,
metadata: {
knowledgeBaseName: accessCheck.knowledgeBase?.name,
fileName: singleDocumentData.filename,
fileType: singleDocumentData.mimeType,
fileSize: singleDocumentData.fileSize,
},
request: req,
})
return NextResponse.json({
success: true,
data: newDocument,
})
} catch (error) {
logger.error(`[${requestId}] Error creating document`, error)
if (error instanceof KnowledgeBaseFileOwnershipError) {
return NextResponse.json(
{ error: 'File URL does not reference a file owned by this knowledge base' },
{ status: 403 }
)
}
const errorMessage = getErrorMessage(error, 'Failed to create document')
const isStorageLimitError =
errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found'
return NextResponse.json(
{ error: errorMessage },
{ status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 }
)
}
}
)
export const PATCH = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateId().slice(0, 8)
const { id: knowledgeBaseId } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized bulk document operation attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
if ('notFound' in accessCheck && accessCheck.notFound) {
logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`)
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${session.user.id} attempted to perform bulk operation on unauthorized knowledge base ${knowledgeBaseId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(
bulkKnowledgeDocumentsContract,
req,
{ params },
{
validationErrorResponse: (error) => {
logger.warn(`[${requestId}] Invalid bulk operation data`, { errors: error.issues })
return NextResponse.json(
{ error: 'Invalid request data', details: error.issues },
{ status: 400 }
)
},
}
)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
const { operation, documentIds, selectAll, enabledFilter } = validatedData
try {
let result
if (selectAll) {
result = await bulkDocumentOperationByFilter(
knowledgeBaseId,
operation,
enabledFilter,
requestId
)
} else if (documentIds && documentIds.length > 0) {
result = await bulkDocumentOperation(knowledgeBaseId, operation, documentIds, requestId)
} else {
return NextResponse.json({ error: 'No documents specified' }, { status: 400 })
}
return NextResponse.json({
success: true,
data: {
operation,
successCount: result.successCount,
updatedDocuments: result.updatedDocuments,
},
})
} catch (error) {
if (error instanceof Error && error.message === 'No valid documents found to update') {
return NextResponse.json({ error: 'No valid documents found to update' }, { status: 404 })
}
throw error
}
} catch (error) {
logger.error(`[${requestId}] Error in bulk document operation`, error)
return NextResponse.json({ error: 'Failed to perform bulk operation' }, { status: 500 })
}
}
)
@@ -0,0 +1,116 @@
/**
* Tests for knowledge base document upsert API route
*
* @vitest-environment node
*/
import {
auditMock,
createMockRequest,
hybridAuthMock,
hybridAuthMockFns,
knowledgeApiUtilsMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDbChain } = vi.hoisted(() => {
const chain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]),
}
return { mockDbChain: chain }
})
vi.mock('@sim/db', () => ({ db: mockDbChain }))
vi.mock('@/lib/auth/hybrid', () => hybridAuthMock)
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
vi.mock('@sim/audit', () => auditMock)
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
checkActorUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }),
}))
vi.mock('@/lib/knowledge/documents/service', () => ({
createDocumentRecords: vi.fn(),
deleteDocument: vi.fn(),
getProcessingConfig: vi.fn().mockReturnValue({ maxConcurrentDocuments: 1, batchSize: 1 }),
processDocumentsWithQueue: vi.fn(),
KnowledgeBaseFileOwnershipError: class KnowledgeBaseFileOwnershipError extends Error {},
}))
import { createDocumentRecords, processDocumentsWithQueue } from '@/lib/knowledge/documents/service'
import { POST } from '@/app/api/knowledge/[id]/documents/upsert/route'
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
describe('POST /api/knowledge/[id]/documents/upsert', () => {
const params = Promise.resolve({ id: 'kb-123' })
beforeEach(() => {
vi.clearAllMocks()
mockDbChain.select.mockReturnThis()
mockDbChain.from.mockReturnThis()
mockDbChain.where.mockReturnThis()
mockDbChain.limit.mockResolvedValue([])
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'session',
userName: 'Test User',
userEmail: 'test@example.com',
})
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValue({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-1', workspaceId: 'ws-1', name: 'KB' },
} as any)
vi.mocked(createDocumentRecords).mockResolvedValue([
{ documentId: 'doc-new', filename: 'note.txt' },
] as any)
vi.mocked(processDocumentsWithQueue).mockResolvedValue(undefined as any)
})
const baseBody = {
filename: 'note.txt',
fileSize: 11,
mimeType: 'text/plain',
}
it('accepts a data: URI', async () => {
const req = createMockRequest('POST', {
...baseBody,
fileUrl: 'data:text/plain;base64,SGVsbG8gd29ybGQ=',
})
const res = await POST(req, { params })
expect(res.status).toBe(200)
expect(createDocumentRecords).toHaveBeenCalled()
})
it('accepts an https URL', async () => {
const req = createMockRequest('POST', {
...baseBody,
fileUrl: 'https://example.com/note.txt',
})
const res = await POST(req, { params })
expect(res.status).toBe(200)
expect(createDocumentRecords).toHaveBeenCalled()
})
it.each([
['absolute local path', '/etc/passwd'],
['app config path', '/app/.env'],
['file:// URL', 'file:///etc/passwd'],
['relative serve path', '/api/files/serve/kb/foo.pdf'],
['ftp URL', 'ftp://example.com/file.pdf'],
['parent traversal', '../../etc/passwd'],
['windows path', 'C:\\Windows\\System32\\config\\SAM'],
])('rejects %s with 400 and never invokes the pipeline', async (_label, fileUrl) => {
const req = createMockRequest('POST', { ...baseBody, fileUrl })
const res = await POST(req, { params })
expect(res.status).toBe(400)
expect(createDocumentRecords).not.toHaveBeenCalled()
expect(processDocumentsWithQueue).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,258 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { document } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { upsertKnowledgeDocumentContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
createDocumentRecords,
deleteDocument,
getProcessingConfig,
KnowledgeBaseFileOwnershipError,
processDocumentsWithQueue,
} from '@/lib/knowledge/documents/service'
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('DocumentUpsertAPI')
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateId().slice(0, 8)
const { id: knowledgeBaseId } = await context.params
try {
const parsed = await parseRequest(upsertKnowledgeDocumentContract, req, context)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
logger.info(`[${requestId}] Knowledge base document upsert request`, {
knowledgeBaseId,
hasDocumentId: !!validatedData.documentId,
filename: validatedData.filename,
})
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
if (validatedData.workflowId) {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: validatedData.workflowId,
userId,
action: 'write',
})
if (!authorization.allowed) {
return NextResponse.json(
{ error: authorization.message || 'Access denied' },
{ status: authorization.status }
)
}
}
const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId)
if (!accessCheck.hasAccess) {
if ('notFound' in accessCheck && accessCheck.notFound) {
logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`)
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${userId} attempted to upsert document in unauthorized knowledge base ${knowledgeBaseId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Gate usage before any create/delete so an over-limit upsert is rejected up
// front and never deletes the existing (already-indexed) document. Runs even
// for legacy KBs with no workspace — the uploader's pooled/frozen status is
// still enforced (per-member is skipped when there's no org workspace).
const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId
const usage = await checkActorUsageLimits(userId, kbWorkspaceId)
if (usage.isExceeded) {
return NextResponse.json(
{
error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.',
},
{ status: 402 }
)
}
let existingDocumentId: string | null = null
let isUpdate = false
if (validatedData.documentId) {
const existingDoc = await db
.select({ id: document.id })
.from(document)
.where(
and(
eq(document.id, validatedData.documentId),
eq(document.knowledgeBaseId, knowledgeBaseId),
isNull(document.deletedAt)
)
)
.limit(1)
if (existingDoc.length > 0) {
existingDocumentId = existingDoc[0].id
}
} else {
const docsByFilename = await db
.select({ id: document.id })
.from(document)
.where(
and(
eq(document.filename, validatedData.filename),
eq(document.knowledgeBaseId, knowledgeBaseId),
isNull(document.deletedAt)
)
)
.limit(1)
if (docsByFilename.length > 0) {
existingDocumentId = docsByFilename[0].id
}
}
if (existingDocumentId) {
isUpdate = true
logger.info(
`[${requestId}] Found existing document ${existingDocumentId}, creating replacement before deleting old`
)
}
const createdDocuments = await createDocumentRecords(
[
{
filename: validatedData.filename,
fileUrl: validatedData.fileUrl,
fileSize: validatedData.fileSize,
mimeType: validatedData.mimeType,
...(validatedData.documentTagsData && {
documentTagsData: validatedData.documentTagsData,
}),
},
],
knowledgeBaseId,
requestId,
userId
)
const firstDocument = createdDocuments[0]
if (!firstDocument) {
logger.error(`[${requestId}] createDocumentRecords returned empty array unexpectedly`)
return NextResponse.json({ error: 'Failed to create document record' }, { status: 500 })
}
if (existingDocumentId) {
try {
await deleteDocument(existingDocumentId, requestId)
} catch (deleteError) {
logger.error(
`[${requestId}] Failed to delete old document ${existingDocumentId}, rolling back new record`,
deleteError
)
await deleteDocument(firstDocument.documentId, requestId).catch(() => {})
return NextResponse.json(
{ error: 'Failed to replace existing document' },
{ status: 500 }
)
}
}
processDocumentsWithQueue(
createdDocuments,
knowledgeBaseId,
validatedData.processingOptions ?? {},
requestId
).catch((error: unknown) => {
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
})
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
PlatformEvents.knowledgeBaseDocumentsUploaded({
knowledgeBaseId,
documentsCount: 1,
uploadType: 'single',
recipe: validatedData.processingOptions?.recipe,
})
} catch (_e) {
// Silently fail
}
recordAudit({
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: isUpdate ? AuditAction.DOCUMENT_UPDATED : AuditAction.DOCUMENT_UPLOADED,
resourceType: AuditResourceType.DOCUMENT,
resourceId: knowledgeBaseId,
resourceName: validatedData.filename,
description: isUpdate
? `Upserted (replaced) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"`
: `Upserted (created) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"`,
metadata: {
knowledgeBaseName: accessCheck.knowledgeBase?.name,
fileName: validatedData.filename,
fileType: validatedData.mimeType,
fileSize: validatedData.fileSize,
previousDocumentId: existingDocumentId,
isUpdate,
},
request: req,
})
return NextResponse.json({
success: true,
data: {
documentsCreated: [
{
documentId: firstDocument.documentId,
filename: firstDocument.filename,
status: 'pending',
},
],
isUpdate,
previousDocumentId: existingDocumentId,
processingMethod: 'background',
processingConfig: {
maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments,
batchSize: getProcessingConfig().batchSize,
},
},
})
} catch (error) {
logger.error(`[${requestId}] Error upserting document`, error)
if (error instanceof KnowledgeBaseFileOwnershipError) {
return NextResponse.json(
{ error: 'File URL does not reference a file owned by this knowledge base' },
{ status: 403 }
)
}
const errorMessage = getErrorMessage(error, 'Failed to upsert document')
const isStorageLimitError =
errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found'
return NextResponse.json(
{ error: errorMessage },
{ status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 }
)
}
}
)
@@ -0,0 +1,77 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { nextAvailableSlotContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getNextAvailableSlot, getTagDefinitions } from '@/lib/knowledge/tags/service'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('NextAvailableSlotAPI')
// GET /api/knowledge/[id]/next-available-slot - Get the next available tag slot for a knowledge base and field type
export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateId().slice(0, 8)
const parsed = await parseRequest(nextAvailableSlotContract, req, context)
if (!parsed.success) {
return NextResponse.json({ error: 'fieldType parameter is required' }, { status: 400 })
}
const { id: knowledgeBaseId } = parsed.data.params
const { fieldType } = parsed.data.query
try {
logger.info(
`[${requestId}] Getting next available slot for knowledge base ${knowledgeBaseId}, fieldType: ${fieldType}`
)
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json(
{ error: accessCheck.notFound ? 'Not found' : 'Forbidden' },
{ status: accessCheck.notFound ? 404 : 403 }
)
}
// Get existing definitions once and reuse
const existingDefinitions = await getTagDefinitions(knowledgeBaseId)
const usedSlots = existingDefinitions
.filter((def) => def.fieldType === fieldType)
.map((def) => def.tagSlot)
// Create a map for efficient lookup and pass to avoid redundant query
const existingBySlot = new Map(existingDefinitions.map((def) => [def.tagSlot as string, def]))
const nextAvailableSlot = await getNextAvailableSlot(
knowledgeBaseId,
fieldType,
existingBySlot
)
logger.info(
`[${requestId}] Next available slot for fieldType ${fieldType}: ${nextAvailableSlot}`
)
const result = {
nextAvailableSlot,
fieldType,
usedSlots,
totalSlots: 7,
availableSlots: nextAvailableSlot ? 7 - usedSlots.length : 0,
}
return NextResponse.json({
success: true,
data: result,
})
} catch (error) {
logger.error(`[${requestId}] Error getting next available slot`, error)
return NextResponse.json({ error: 'Failed to get next available slot' }, { status: 500 })
}
}
)
@@ -0,0 +1,67 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { restoreKnowledgeBaseContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
getRestorableKnowledgeBase,
performRestoreKnowledgeBase,
} from '@/lib/knowledge/orchestration'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('RestoreKnowledgeBaseAPI')
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const parsed = await parseRequest(restoreKnowledgeBaseContract, request, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
try {
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const kb = await getRestorableKnowledgeBase(id)
if (!kb) {
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
if (kb.workspaceId) {
const permission = await getUserEntityPermissions(auth.userId, 'workspace', kb.workspaceId)
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
} else if (kb.userId !== auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const result = await performRestoreKnowledgeBase({
knowledgeBaseId: id,
userId: auth.userId,
requestId,
})
if (!result.success) {
const status =
result.errorCode === 'not_found' ? 404 : result.errorCode === 'conflict' ? 409 : 500
return NextResponse.json({ error: result.error }, { status })
}
logger.info(`[${requestId}] Restored knowledge base ${id}`)
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`[${requestId}] Error restoring knowledge base ${id}`, error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Internal server error') },
{ status: 500 }
)
}
}
)
@@ -0,0 +1,463 @@
/**
* Tests for knowledge base by ID API route
*
* @vitest-environment node
*/
import { auditMock, authMockFns, createMockRequest, knowledgeApiUtilsMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDbChain } = vi.hoisted(() => {
const mockDbChain = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
}
return { mockDbChain }
})
vi.mock('@sim/db', () => ({
db: mockDbChain,
}))
vi.mock('@sim/audit', () => auditMock)
vi.mock('@/lib/knowledge/service', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/knowledge/service')>()
return {
...actual,
getKnowledgeBaseById: vi.fn(),
updateKnowledgeBase: vi.fn(),
deleteKnowledgeBase: vi.fn(),
KnowledgeBasePermissionError: actual.KnowledgeBasePermissionError,
}
})
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
import {
deleteKnowledgeBase,
getKnowledgeBaseById,
KnowledgeBasePermissionError,
updateKnowledgeBase,
} from '@/lib/knowledge/service'
import { DELETE, GET, PUT } from '@/app/api/knowledge/[id]/route'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
describe('Knowledge Base By ID API Route', () => {
const mockKnowledgeBase = {
id: 'kb-123',
userId: 'user-123',
name: 'Test Knowledge Base',
description: 'Test description',
tokenCount: 100,
embeddingModel: 'text-embedding-3-small',
embeddingDimension: 1536,
chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 },
createdAt: new Date(),
updatedAt: new Date(),
workspaceId: null,
deletedAt: null,
}
const resetMocks = () => {
vi.clearAllMocks()
Object.values(mockDbChain).forEach((fn) => {
if (typeof fn === 'function') {
fn.mockClear().mockReset().mockReturnThis()
}
})
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
})
})
afterEach(() => {
vi.clearAllMocks()
})
describe('GET /api/knowledge/[id]', () => {
const mockParams = Promise.resolve({ id: 'kb-123' })
it('should retrieve knowledge base successfully for authenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(getKnowledgeBaseById).mockResolvedValueOnce(mockKnowledgeBase)
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.id).toBe('kb-123')
expect(data.data.name).toBe('Test Knowledge Base')
expect(checkKnowledgeBaseAccess).toHaveBeenCalledWith('kb-123', 'user-123')
expect(getKnowledgeBaseById).toHaveBeenCalledWith('kb-123')
})
it('should return unauthorized for unauthenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should return not found for non-existent knowledge base', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValueOnce({
hasAccess: false,
notFound: true,
})
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('Knowledge base not found')
})
it('should return unauthorized for knowledge base owned by different user', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValueOnce({
hasAccess: false,
notFound: false,
})
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should return not found when service returns null', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseAccess).mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(getKnowledgeBaseById).mockResolvedValueOnce(null)
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('Knowledge base not found')
})
it('should handle database errors', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseAccess).mockRejectedValueOnce(new Error('Database error'))
const req = createMockRequest('GET')
const response = await GET(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Failed to fetch knowledge base')
})
})
describe('PUT /api/knowledge/[id]', () => {
const mockParams = Promise.resolve({ id: 'kb-123' })
const validUpdateData = {
name: 'Updated Knowledge Base',
description: 'Updated description',
}
it('should update knowledge base successfully', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
resetMocks()
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
const updatedKnowledgeBase = { ...mockKnowledgeBase, ...validUpdateData }
vi.mocked(updateKnowledgeBase).mockResolvedValueOnce(updatedKnowledgeBase)
const req = createMockRequest('PUT', validUpdateData)
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.name).toBe('Updated Knowledge Base')
expect(checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-123', 'user-123')
expect(updateKnowledgeBase).toHaveBeenCalledWith(
'kb-123',
{
name: validUpdateData.name,
description: validUpdateData.description,
workspaceId: undefined,
chunkingConfig: undefined,
},
expect.any(String),
{ actorUserId: 'user-123' }
)
})
it('returns 403 when service rejects a cross-workspace transfer', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'attacker', email: 'a@example.com' },
})
resetMocks()
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123', workspaceId: 'ws-current' },
})
vi.mocked(updateKnowledgeBase).mockRejectedValueOnce(
new KnowledgeBasePermissionError('User does not have permission on the target workspace')
)
const req = createMockRequest('PUT', { workspaceId: 'ws-target' })
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('User does not have permission on the target workspace')
})
it('returns 403 when service rejects clearing workspaceId', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
resetMocks()
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123', workspaceId: 'ws-current' },
})
vi.mocked(updateKnowledgeBase).mockRejectedValueOnce(
new KnowledgeBasePermissionError('Knowledge base workspace cannot be cleared')
)
const req = createMockRequest('PUT', { workspaceId: null })
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('Knowledge base workspace cannot be cleared')
})
it('should return unauthorized for unauthenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = createMockRequest('PUT', validUpdateData)
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should return not found for non-existent knowledge base', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
resetMocks()
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({
hasAccess: false,
notFound: true,
})
const req = createMockRequest('PUT', validUpdateData)
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('Knowledge base not found')
})
it('should validate update data', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
resetMocks()
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
const invalidData = {
name: '',
}
const req = createMockRequest('PUT', invalidData)
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Validation error')
expect(data.details).toBeDefined()
})
it('should handle database errors during update', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(updateKnowledgeBase).mockRejectedValueOnce(new Error('Database error'))
const req = createMockRequest('PUT', validUpdateData)
const response = await PUT(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Failed to update knowledge base')
})
})
describe('DELETE /api/knowledge/[id]', () => {
const mockParams = Promise.resolve({ id: 'kb-123' })
it('should delete knowledge base successfully', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
resetMocks()
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(deleteKnowledgeBase).mockResolvedValueOnce(undefined)
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.data.message).toBe('Knowledge base deleted successfully')
expect(checkKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-123', 'user-123')
expect(deleteKnowledgeBase).toHaveBeenCalledWith('kb-123', expect.any(String))
})
it('should return unauthorized for unauthenticated user', async () => {
authMockFns.mockGetSession.mockResolvedValue(null)
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should return not found for non-existent knowledge base', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
resetMocks()
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({
hasAccess: false,
notFound: true,
})
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('Knowledge base not found')
})
it('should return unauthorized for knowledge base owned by different user', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
resetMocks()
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({
hasAccess: false,
notFound: false,
})
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('Unauthorized')
})
it('should handle database errors during delete', async () => {
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-123', email: 'test@example.com' },
})
vi.mocked(checkKnowledgeBaseWriteAccess).mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
})
vi.mocked(deleteKnowledgeBase).mockRejectedValueOnce(new Error('Database error'))
const req = createMockRequest('DELETE')
const response = await DELETE(req, { params: mockParams })
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('Failed to delete knowledge base')
})
})
})
+220
View File
@@ -0,0 +1,220 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { updateKnowledgeBaseContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
deleteKnowledgeBase,
getKnowledgeBaseById,
KnowledgeBaseConflictError,
KnowledgeBasePermissionError,
updateKnowledgeBase,
} from '@/lib/knowledge/service'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
const logger = createLogger('KnowledgeBaseByIdAPI')
export const GET = withRouteHandler(
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const { id } = await params
try {
const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized knowledge base access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const accessCheck = await checkKnowledgeBaseAccess(id, userId)
if (!accessCheck.hasAccess) {
if ('notFound' in accessCheck && accessCheck.notFound) {
logger.warn(`[${requestId}] Knowledge base not found: ${id}`)
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${userId} attempted to access unauthorized knowledge base ${id}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const knowledgeBaseData = await getKnowledgeBaseById(id)
if (!knowledgeBaseData) {
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
logger.info(`[${requestId}] Retrieved knowledge base: ${id} for user ${userId}`)
return NextResponse.json({
success: true,
data: knowledgeBaseData,
})
} catch (error) {
logger.error(`[${requestId}] Error fetching knowledge base`, error)
return NextResponse.json({ error: 'Failed to fetch knowledge base' }, { status: 500 })
}
}
)
export const PUT = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const { id } = await context.params
try {
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized knowledge base update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId)
if (!accessCheck.hasAccess) {
if ('notFound' in accessCheck && accessCheck.notFound) {
logger.warn(`[${requestId}] Knowledge base not found: ${id}`)
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${userId} attempted to update unauthorized knowledge base ${id}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(updateKnowledgeBaseContract, req, context)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
const updatedKnowledgeBase = await updateKnowledgeBase(
id,
{
name: validatedData.name,
description: validatedData.description,
workspaceId: validatedData.workspaceId,
chunkingConfig: validatedData.chunkingConfig,
},
requestId,
{ actorUserId: userId }
)
logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`)
recordAudit({
workspaceId: accessCheck.knowledgeBase.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.KNOWLEDGE_BASE_UPDATED,
resourceType: AuditResourceType.KNOWLEDGE_BASE,
resourceId: id,
resourceName: validatedData.name ?? updatedKnowledgeBase.name,
description: `Updated knowledge base "${validatedData.name ?? updatedKnowledgeBase.name}"`,
metadata: {
updatedFields: Object.keys(validatedData).filter(
(k) => validatedData[k as keyof typeof validatedData] !== undefined
),
...(validatedData.name && { newName: validatedData.name }),
...(validatedData.description !== undefined && {
description: validatedData.description,
}),
...(validatedData.chunkingConfig && {
chunkMaxSize: validatedData.chunkingConfig.maxSize,
chunkMinSize: validatedData.chunkingConfig.minSize,
chunkOverlap: validatedData.chunkingConfig.overlap,
}),
},
request: req,
})
return NextResponse.json({
success: true,
data: updatedKnowledgeBase,
})
} catch (error) {
if (error instanceof KnowledgeBaseConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}
if (error instanceof KnowledgeBasePermissionError) {
logger.warn(`[${requestId}] Forbidden knowledge base update on ${id}: ${error.message}`)
return NextResponse.json({ error: error.message }, { status: 403 })
}
logger.error(`[${requestId}] Error updating knowledge base`, error)
return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 })
}
}
)
export const DELETE = withRouteHandler(
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
const { id } = await params
try {
const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized knowledge base delete attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = auth.userId
const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId)
if (!accessCheck.hasAccess) {
if ('notFound' in accessCheck && accessCheck.notFound) {
logger.warn(`[${requestId}] Knowledge base not found: ${id}`)
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
logger.warn(
`[${requestId}] User ${userId} attempted to delete unauthorized knowledge base ${id}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
await deleteKnowledgeBase(id, requestId)
try {
PlatformEvents.knowledgeBaseDeleted({
knowledgeBaseId: id,
})
} catch {
// Telemetry should not fail the operation
}
logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${userId}`)
recordAudit({
workspaceId: accessCheck.knowledgeBase.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.KNOWLEDGE_BASE_DELETED,
resourceType: AuditResourceType.KNOWLEDGE_BASE,
resourceId: id,
resourceName: accessCheck.knowledgeBase.name,
description: `Deleted knowledge base "${accessCheck.knowledgeBase.name || id}"`,
metadata: {
knowledgeBaseName: accessCheck.knowledgeBase.name,
},
request: _request,
})
return NextResponse.json({
success: true,
data: { message: 'Knowledge base deleted successfully' },
})
} catch (error) {
logger.error(`[${requestId}] Error deleting knowledge base`, error)
return NextResponse.json({ error: 'Failed to delete knowledge base' }, { status: 500 })
}
}
)
@@ -0,0 +1,52 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { deleteTagDefinitionContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { deleteTagDefinition } from '@/lib/knowledge/tags/service'
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('TagDefinitionAPI')
// DELETE /api/knowledge/[id]/tag-definitions/[tagId] - Delete a tag definition
export const DELETE = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string; tagId: string }> }) => {
const requestId = generateId().slice(0, 8)
const parsed = await parseRequest(deleteTagDefinitionContract, req, context)
if (!parsed.success) return parsed.response
const { id: knowledgeBaseId, tagId } = parsed.data.params
try {
logger.info(
`[${requestId}] Deleting tag definition ${tagId} from knowledge base ${knowledgeBaseId}`
)
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
return NextResponse.json(
{ error: accessCheck.notFound ? 'Not found' : 'Forbidden' },
{ status: accessCheck.notFound ? 404 : 403 }
)
}
const deletedTag = await deleteTagDefinition(knowledgeBaseId, tagId, requestId)
return NextResponse.json({
success: true,
message: `Tag definition "${deletedTag.displayName}" deleted successfully`,
})
} catch (error) {
logger.error(`[${requestId}] Error deleting tag definition`, error)
return NextResponse.json({ error: 'Failed to delete tag definition' }, { status: 500 })
}
}
)
@@ -0,0 +1,113 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { createTagDefinitionContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants'
import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service'
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('KnowledgeBaseTagDefinitionsAPI')
// GET /api/knowledge/[id]/tag-definitions - Get all tag definitions for a knowledge base
export const GET = withRouteHandler(
async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
const requestId = generateId().slice(0, 8)
const { id: knowledgeBaseId } = await params
try {
logger.info(`[${requestId}] Getting tag definitions for knowledge base ${knowledgeBaseId}`)
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
// For session auth, verify KB access. Internal JWT is trusted.
if (auth.authType === AuthType.SESSION && auth.userId) {
const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
return NextResponse.json(
{ error: accessCheck.notFound ? 'Not found' : 'Forbidden' },
{ status: accessCheck.notFound ? 404 : 403 }
)
}
}
const tagDefinitions = await getTagDefinitions(knowledgeBaseId)
logger.info(
`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions (${auth.authType})`
)
return NextResponse.json({
success: true,
data: tagDefinitions,
})
} catch (error) {
logger.error(`[${requestId}] Error getting tag definitions`, error)
return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 })
}
}
)
// POST /api/knowledge/[id]/tag-definitions - Create a new tag definition
export const POST = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateId().slice(0, 8)
const { id: knowledgeBaseId } = await context.params
try {
logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`)
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
// For session auth, verify KB access. Internal JWT is trusted.
if (auth.authType === AuthType.SESSION && auth.userId) {
const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
return NextResponse.json(
{ error: accessCheck.notFound ? 'Not found' : 'Forbidden' },
{ status: accessCheck.notFound ? 404 : 403 }
)
}
}
const parsed = await parseRequest(createTagDefinitionContract, req, context)
if (!parsed.success) return parsed.response
const validatedData = parsed.data.body
if (!(SUPPORTED_FIELD_TYPES as readonly string[]).includes(validatedData.fieldType)) {
return NextResponse.json(
{ error: 'Invalid request data', details: 'Invalid field type' },
{ status: 400 }
)
}
const newTagDefinition = await createTagDefinition(
{
knowledgeBaseId,
tagSlot: validatedData.tagSlot,
displayName: validatedData.displayName,
fieldType: validatedData.fieldType,
},
requestId
)
return NextResponse.json({
success: true,
data: newTagDefinition,
})
} catch (error) {
logger.error(`[${requestId}] Error creating tag definition`, error)
return NextResponse.json({ error: 'Failed to create tag definition' }, { status: 500 })
}
}
)
@@ -0,0 +1,56 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { getTagUsageContract } from '@/lib/api/contracts/knowledge'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getTagUsage } from '@/lib/knowledge/tags/service'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('TagUsageAPI')
// GET /api/knowledge/[id]/tag-usage - Get usage statistics for all tag definitions
export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateId().slice(0, 8)
const parsed = await parseRequest(getTagUsageContract, req, context)
if (!parsed.success) return parsed.response
const { id: knowledgeBaseId } = parsed.data.params
try {
logger.info(
`[${requestId}] Getting tag usage statistics for knowledge base ${knowledgeBaseId}`
)
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json(
{ error: accessCheck.notFound ? 'Not found' : 'Forbidden' },
{ status: accessCheck.notFound ? 404 : 403 }
)
}
const usageStats = await getTagUsage(knowledgeBaseId, requestId)
logger.info(
`[${requestId}] Retrieved usage statistics for ${usageStats.length} tag definitions`
)
return NextResponse.json({
success: true,
data: usageStats,
})
} catch (error) {
logger.error(`[${requestId}] Error getting tag usage statistics`, error)
return NextResponse.json({ error: 'Failed to get tag usage statistics' }, { status: 500 })
}
}
)