chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user