Skip to content

Commit 8d58a11

Browse files
refactor(api): migrate v2 knowledge operations
1 parent b177b3a commit 8d58a11

71 files changed

Lines changed: 7291 additions & 2984 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts

Lines changed: 40 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,15 @@
11
import { type NextRequest, NextResponse } from 'next/server'
22
import { completeKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions'
33
import { parseRequest } from '@/lib/api/server'
4-
import { generateRequestId } from '@/lib/core/utils/request'
4+
import { PlatformEvents } from '@/lib/core/telemetry'
55
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6-
import { completeUploadSession } from '@/lib/uploads/upload-session/service'
7-
import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils'
6+
import { completeKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions'
7+
import { captureServerEvent } from '@/lib/posthog/server'
88
import {
9-
requireKnowledgeDocumentUploadAccess,
9+
knowledgeDocumentUploadErrorResponse,
1010
requireKnowledgeDocumentUploadActor,
11-
resolveKnowledgeDocumentUploadAttribution,
1211
} from '@/app/api/knowledge/[id]/documents/uploads/utils'
13-
import {
14-
finalizeKnowledgeDocumentUpload,
15-
getOwnedKnowledgeDocumentUpload,
16-
toV2KnowledgeDocumentUpload,
17-
} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
12+
import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
1813

1914
interface KnowledgeDocumentUploadRouteParams {
2015
params: Promise<{ id: string; uploadId: string }>
@@ -28,44 +23,46 @@ export const POST = withRouteHandler(
2823
if (!parsed.success) return parsed.response
2924
const { id: knowledgeBaseId, uploadId } = parsed.data.params
3025
const { workspaceId } = parsed.data.query
31-
const access = await requireKnowledgeDocumentUploadAccess({
32-
knowledgeBaseId,
33-
workspaceId,
34-
userId: actor.id,
35-
})
36-
if (access instanceof NextResponse) return access
37-
const requestId = generateRequestId()
3826
try {
39-
const upload = await getOwnedKnowledgeDocumentUpload({
40-
knowledgeBaseId,
41-
uploadId,
42-
workspaceId,
43-
userId: actor.id,
44-
uploadToken: parsed.data.headers['upload-token'],
45-
})
46-
const completed = await completeUploadSession({
47-
session: upload,
48-
finalize: (claimed) =>
49-
finalizeKnowledgeDocumentUpload({
50-
claimed,
51-
knowledgeBaseId,
52-
knowledgeBaseName: access.knowledgeBase.name,
53-
workspaceId,
54-
userId: actor.id,
55-
resolveAttribution: () =>
56-
resolveKnowledgeDocumentUploadAttribution({ workspaceId, userId: actor.id }),
57-
source: 'ui',
58-
requestId,
59-
request,
60-
actorName: actor.name,
61-
actorEmail: actor.email,
62-
}),
27+
const completed = await completeKnowledgeDocumentUpload.execute({
28+
principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId },
29+
input: {
30+
knowledgeBaseId,
31+
assertedWorkspaceId: workspaceId,
32+
uploadId,
33+
uploadToken: parsed.data.headers['upload-token'],
34+
source: 'ui',
35+
},
36+
request,
6337
})
38+
if (completed.value.created) {
39+
captureServerEvent(
40+
actor.id,
41+
'knowledge_base_document_uploaded',
42+
{
43+
knowledge_base_id: completed.knowledgeBaseId,
44+
workspace_id: completed.workspaceId,
45+
document_count: 1,
46+
upload_type: 'single',
47+
},
48+
{
49+
groups: { workspace: completed.workspaceId },
50+
setOnce: { first_document_uploaded_at: new Date().toISOString() },
51+
}
52+
)
53+
PlatformEvents.knowledgeBaseDocumentsUploaded({
54+
knowledgeBaseId: completed.knowledgeBaseId,
55+
documentsCount: 1,
56+
uploadType: 'single',
57+
mimeType: completed.value.document.mimeType,
58+
fileSize: completed.value.document.fileSize,
59+
})
60+
}
6461
return NextResponse.json({
65-
data: toV2KnowledgeDocumentUpload(completed.session, completed.value),
62+
data: toV2KnowledgeDocumentUpload(completed.session, completed.value.document),
6663
})
6764
} catch (error) {
68-
const classified = uploadSessionErrorResponse(error)
65+
const classified = knowledgeDocumentUploadErrorResponse(error)
6966
if (classified) return classified
7067
throw error
7168
}

apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@ import { type NextRequest, NextResponse } from 'next/server'
22
import { createKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/knowledge/upload-sessions'
33
import { parseRequest } from '@/lib/api/server'
44
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5-
import { createUploadPartUrls } from '@/lib/uploads/upload-session/service'
6-
import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils'
5+
import { issueKnowledgeDocumentUploadParts } from '@/lib/knowledge/application/upload-sessions'
76
import {
8-
requireKnowledgeDocumentUploadAccess,
7+
knowledgeDocumentUploadErrorResponse,
98
requireKnowledgeDocumentUploadActor,
109
} from '@/app/api/knowledge/[id]/documents/uploads/utils'
11-
import { getOwnedKnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
1210

1311
interface KnowledgeDocumentUploadRouteParams {
1412
params: Promise<{ id: string; uploadId: string }>
@@ -26,28 +24,21 @@ export const POST = withRouteHandler(
2624
if (!parsed.success) return parsed.response
2725
const { id: knowledgeBaseId, uploadId } = parsed.data.params
2826
const { workspaceId } = parsed.data.query
29-
const access = await requireKnowledgeDocumentUploadAccess({
30-
knowledgeBaseId,
31-
workspaceId,
32-
userId: actor.id,
33-
})
34-
if (access instanceof NextResponse) return access
3527
try {
36-
const upload = await getOwnedKnowledgeDocumentUpload({
37-
knowledgeBaseId,
38-
uploadId,
39-
workspaceId,
40-
userId: actor.id,
41-
uploadToken: parsed.data.headers['upload-token'],
42-
})
43-
const parts = await createUploadPartUrls({
44-
session: upload,
45-
partNumbers: parsed.data.body.partNumbers,
46-
localOrigin: request.nextUrl.origin,
28+
const { parts } = await issueKnowledgeDocumentUploadParts.execute({
29+
principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId },
30+
input: {
31+
knowledgeBaseId,
32+
assertedWorkspaceId: workspaceId,
33+
uploadId,
34+
uploadToken: parsed.data.headers['upload-token'],
35+
partNumbers: parsed.data.body.partNumbers,
36+
},
37+
request,
4738
})
4839
return NextResponse.json({ data: { parts } })
4940
} catch (error) {
50-
const classified = uploadSessionErrorResponse(error)
41+
const classified = knowledgeDocumentUploadErrorResponse(error)
5142
if (classified) return classified
5243
throw error
5344
}

apps/sim/app/api/knowledge/[id]/documents/uploads/[uploadId]/route.ts

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,12 @@ import { type NextRequest, NextResponse } from 'next/server'
22
import { abortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions'
33
import { parseRequest } from '@/lib/api/server'
44
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5-
import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils'
5+
import { cancelKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions'
66
import {
7-
requireKnowledgeDocumentUploadAccess,
7+
knowledgeDocumentUploadErrorResponse,
88
requireKnowledgeDocumentUploadActor,
99
} from '@/app/api/knowledge/[id]/documents/uploads/utils'
10-
import {
11-
abortKnowledgeDocumentUpload,
12-
getOwnedKnowledgeDocumentUpload,
13-
toV2KnowledgeDocumentUpload,
14-
} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
10+
import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
1511

1612
interface KnowledgeDocumentUploadRouteParams {
1713
params: Promise<{ id: string; uploadId: string }>
@@ -25,24 +21,20 @@ export const DELETE = withRouteHandler(
2521
if (!parsed.success) return parsed.response
2622
const { id: knowledgeBaseId, uploadId } = parsed.data.params
2723
const { workspaceId } = parsed.data.query
28-
const access = await requireKnowledgeDocumentUploadAccess({
29-
knowledgeBaseId,
30-
workspaceId,
31-
userId: actor.id,
32-
})
33-
if (access instanceof NextResponse) return access
3424
try {
35-
const upload = await getOwnedKnowledgeDocumentUpload({
36-
knowledgeBaseId,
37-
uploadId,
38-
workspaceId,
39-
userId: actor.id,
40-
uploadToken: parsed.data.headers['upload-token'],
25+
const aborted = await cancelKnowledgeDocumentUpload.execute({
26+
principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId },
27+
input: {
28+
knowledgeBaseId,
29+
assertedWorkspaceId: workspaceId,
30+
uploadId,
31+
uploadToken: parsed.data.headers['upload-token'],
32+
},
33+
request,
4134
})
42-
const aborted = await abortKnowledgeDocumentUpload(upload, knowledgeBaseId)
4335
return NextResponse.json({ data: toV2KnowledgeDocumentUpload(aborted, null) })
4436
} catch (error) {
45-
const classified = uploadSessionErrorResponse(error)
37+
const classified = knowledgeDocumentUploadErrorResponse(error)
4638
if (classified) return classified
4739
throw error
4840
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
cancel: vi.fn(),
9+
captureServerEvent: vi.fn(),
10+
complete: vi.fn(),
11+
parts: vi.fn(),
12+
platformEvent: vi.fn(),
13+
requireActor: vi.fn(),
14+
}))
15+
16+
vi.mock('@/lib/knowledge/application/upload-sessions', () => ({
17+
cancelKnowledgeDocumentUpload: { execute: mocks.cancel },
18+
completeKnowledgeDocumentUpload: { execute: mocks.complete },
19+
issueKnowledgeDocumentUploadParts: { execute: mocks.parts },
20+
}))
21+
22+
vi.mock('@/lib/core/telemetry', () => ({
23+
PlatformEvents: { knowledgeBaseDocumentsUploaded: mocks.platformEvent },
24+
}))
25+
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent }))
26+
vi.mock('@/app/api/knowledge/[id]/documents/uploads/utils', () => ({
27+
knowledgeDocumentUploadErrorResponse: vi.fn(() => null),
28+
requireKnowledgeDocumentUploadActor: mocks.requireActor,
29+
}))
30+
vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({
31+
toV2KnowledgeDocumentUpload: (_session: unknown, document: unknown) => ({
32+
id: 'upload-1',
33+
knowledgeBaseId: 'kb-1',
34+
status: document ? 'completed' : 'aborted',
35+
name: 'guide.pdf',
36+
contentType: 'application/pdf',
37+
size: 1024,
38+
expiresAt: '2026-08-05T00:00:00.000Z',
39+
error: null,
40+
document,
41+
}),
42+
}))
43+
44+
import { POST as COMPLETE } from '@/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route'
45+
import { POST as PARTS } from '@/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route'
46+
import { DELETE as CANCEL } from '@/app/api/knowledge/[id]/documents/uploads/[uploadId]/route'
47+
48+
const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
49+
const PRINCIPAL = { kind: 'session', userId: 'user-1', sessionId: 'session-1' }
50+
const SESSION = { id: 'upload-1', knowledgeBaseId: 'kb-1' }
51+
const DOCUMENT = {
52+
id: 'upload-1',
53+
knowledgeBaseId: 'kb-1',
54+
filename: 'guide.pdf',
55+
fileSize: 1024,
56+
mimeType: 'application/pdf',
57+
chunkCount: 0,
58+
tokenCount: 0,
59+
characterCount: 0,
60+
enabled: true,
61+
uploadedAt: new Date('2026-08-03T21:01:00.000Z'),
62+
}
63+
64+
function routeContext() {
65+
return { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) }
66+
}
67+
68+
function controlUrl(suffix = '') {
69+
return `http://localhost:3000/api/knowledge/kb-1/documents/uploads/upload-1${suffix}?workspaceId=${WORKSPACE_ID}`
70+
}
71+
72+
describe('internal knowledge-document upload control routes', () => {
73+
beforeEach(() => {
74+
vi.clearAllMocks()
75+
mocks.requireActor.mockResolvedValue({ id: 'user-1', sessionId: 'session-1' })
76+
mocks.parts.mockResolvedValue({
77+
parts: [
78+
{
79+
partNumber: 1,
80+
url: 'https://storage.example/1',
81+
headers: {},
82+
expiresAt: '2026-08-04T21:00:00.000Z',
83+
},
84+
],
85+
})
86+
mocks.cancel.mockResolvedValue(SESSION)
87+
mocks.complete.mockResolvedValue({
88+
session: SESSION,
89+
value: { document: DOCUMENT, created: true, knowledgeBaseName: 'Docs' },
90+
alreadyCompleted: false,
91+
workspaceId: WORKSPACE_ID,
92+
knowledgeBaseId: 'kb-1',
93+
})
94+
})
95+
96+
it('delegates multipart part signing with the current session principal', async () => {
97+
const request = new NextRequest(controlUrl('/parts'), {
98+
method: 'POST',
99+
headers: { 'content-type': 'application/json', 'upload-token': 'token' },
100+
body: JSON.stringify({ partNumbers: [1] }),
101+
})
102+
103+
const response = await PARTS(request, routeContext())
104+
105+
expect(response.status).toBe(200)
106+
expect(mocks.parts).toHaveBeenCalledWith({
107+
principal: PRINCIPAL,
108+
input: {
109+
knowledgeBaseId: 'kb-1',
110+
assertedWorkspaceId: WORKSPACE_ID,
111+
uploadId: 'upload-1',
112+
uploadToken: 'token',
113+
partNumbers: [1],
114+
},
115+
request,
116+
})
117+
})
118+
119+
it('delegates cancellation with the current session principal', async () => {
120+
const request = new NextRequest(controlUrl(), {
121+
method: 'DELETE',
122+
headers: { 'upload-token': 'token' },
123+
})
124+
125+
const response = await CANCEL(request, routeContext())
126+
127+
expect(response.status).toBe(200)
128+
expect(mocks.cancel).toHaveBeenCalledWith({
129+
principal: PRINCIPAL,
130+
input: {
131+
knowledgeBaseId: 'kb-1',
132+
assertedWorkspaceId: WORKSPACE_ID,
133+
uploadId: 'upload-1',
134+
uploadToken: 'token',
135+
},
136+
request,
137+
})
138+
})
139+
140+
it('delegates completion and emits UI analytics only for a new document', async () => {
141+
const request = new NextRequest(controlUrl('/complete'), {
142+
method: 'POST',
143+
headers: { 'upload-token': 'token' },
144+
})
145+
146+
const response = await COMPLETE(request, routeContext())
147+
148+
expect(response.status).toBe(200)
149+
expect(mocks.complete).toHaveBeenCalledWith({
150+
principal: PRINCIPAL,
151+
input: {
152+
knowledgeBaseId: 'kb-1',
153+
assertedWorkspaceId: WORKSPACE_ID,
154+
uploadId: 'upload-1',
155+
uploadToken: 'token',
156+
source: 'ui',
157+
},
158+
request,
159+
})
160+
expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1)
161+
expect(mocks.platformEvent).toHaveBeenCalledTimes(1)
162+
})
163+
164+
it('does not duplicate UI analytics on an idempotent completion retry', async () => {
165+
mocks.complete.mockResolvedValue({
166+
session: SESSION,
167+
value: { document: DOCUMENT, created: false, knowledgeBaseName: 'Docs' },
168+
alreadyCompleted: true,
169+
workspaceId: WORKSPACE_ID,
170+
knowledgeBaseId: 'kb-1',
171+
})
172+
const request = new NextRequest(controlUrl('/complete'), {
173+
method: 'POST',
174+
headers: { 'upload-token': 'token' },
175+
})
176+
177+
await COMPLETE(request, routeContext())
178+
179+
expect(mocks.captureServerEvent).not.toHaveBeenCalled()
180+
expect(mocks.platformEvent).not.toHaveBeenCalled()
181+
})
182+
})

0 commit comments

Comments
 (0)