Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,15 @@
import { type NextRequest, NextResponse } from 'next/server'
import { completeKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions'
import { parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { PlatformEvents } from '@/lib/core/telemetry'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { completeUploadSession } from '@/lib/uploads/upload-session/service'
import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils'
import { completeKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions'
import { captureServerEvent } from '@/lib/posthog/server'
import {
requireKnowledgeDocumentUploadAccess,
knowledgeDocumentUploadErrorResponse,
requireKnowledgeDocumentUploadActor,
resolveKnowledgeDocumentUploadAttribution,
} from '@/app/api/knowledge/[id]/documents/uploads/utils'
import {
finalizeKnowledgeDocumentUpload,
getOwnedKnowledgeDocumentUpload,
toV2KnowledgeDocumentUpload,
} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'

interface KnowledgeDocumentUploadRouteParams {
params: Promise<{ id: string; uploadId: string }>
Expand All@@ -28,44 +23,46 @@ export const POST = withRouteHandler(
if (!parsed.success) return parsed.response
const { id: knowledgeBaseId, uploadId } = parsed.data.params
const { workspaceId } = parsed.data.query
const access = await requireKnowledgeDocumentUploadAccess({
knowledgeBaseId,
workspaceId,
userId: actor.id,
})
if (access instanceof NextResponse) return access
const requestId = generateRequestId()
try {
const upload = await getOwnedKnowledgeDocumentUpload({
knowledgeBaseId,
uploadId,
workspaceId,
userId: actor.id,
uploadToken: parsed.data.headers['upload-token'],
})
const completed = await completeUploadSession({
session: upload,
finalize: (claimed) =>
finalizeKnowledgeDocumentUpload({
claimed,
knowledgeBaseId,
knowledgeBaseName: access.knowledgeBase.name,
workspaceId,
userId: actor.id,
resolveAttribution: () =>
resolveKnowledgeDocumentUploadAttribution({ workspaceId, userId: actor.id }),
source: 'ui',
requestId,
request,
actorName: actor.name,
actorEmail: actor.email,
}),
const completed = await completeKnowledgeDocumentUpload.execute({
principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId },
input: {
knowledgeBaseId,
assertedWorkspaceId: workspaceId,
uploadId,
uploadToken: parsed.data.headers['upload-token'],
source: 'ui',
},
request,
})
if (completed.value.created) {
captureServerEvent(
actor.id,
'knowledge_base_document_uploaded',
{
knowledge_base_id: completed.knowledgeBaseId,
workspace_id: completed.workspaceId,
document_count: 1,
upload_type: 'single',
},
{
groups: { workspace: completed.workspaceId },
setOnce: { first_document_uploaded_at: new Date().toISOString() },
}
)
PlatformEvents.knowledgeBaseDocumentsUploaded({
knowledgeBaseId: completed.knowledgeBaseId,
documentsCount: 1,
uploadType: 'single',
mimeType: completed.value.document.mimeType,
fileSize: completed.value.document.fileSize,
})
}
return NextResponse.json({
data: toV2KnowledgeDocumentUpload(completed.session, completed.value),
data: toV2KnowledgeDocumentUpload(completed.session, completed.value.document),
})
} catch (error) {
const classified = uploadSessionErrorResponse(error)
const classified = knowledgeDocumentUploadErrorResponse(error)
if (classified) return classified
throw error
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,13 +2,11 @@ import { type NextRequest, NextResponse } from 'next/server'
import { createKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/knowledge/upload-sessions'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createUploadPartUrls } from '@/lib/uploads/upload-session/service'
import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils'
import { issueKnowledgeDocumentUploadParts } from '@/lib/knowledge/application/upload-sessions'
import {
requireKnowledgeDocumentUploadAccess,
knowledgeDocumentUploadErrorResponse,
requireKnowledgeDocumentUploadActor,
} from '@/app/api/knowledge/[id]/documents/uploads/utils'
import { getOwnedKnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'

interface KnowledgeDocumentUploadRouteParams {
params: Promise<{ id: string; uploadId: string }>
Expand All@@ -26,28 +24,21 @@ export const POST = withRouteHandler(
if (!parsed.success) return parsed.response
const { id: knowledgeBaseId, uploadId } = parsed.data.params
const { workspaceId } = parsed.data.query
const access = await requireKnowledgeDocumentUploadAccess({
knowledgeBaseId,
workspaceId,
userId: actor.id,
})
if (access instanceof NextResponse) return access
try {
const upload = await getOwnedKnowledgeDocumentUpload({
knowledgeBaseId,
uploadId,
workspaceId,
userId: actor.id,
uploadToken: parsed.data.headers['upload-token'],
})
const parts = await createUploadPartUrls({
session: upload,
partNumbers: parsed.data.body.partNumbers,
localOrigin: request.nextUrl.origin,
const { parts } = await issueKnowledgeDocumentUploadParts.execute({
principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId },
input: {
knowledgeBaseId,
assertedWorkspaceId: workspaceId,
uploadId,
uploadToken: parsed.data.headers['upload-token'],
partNumbers: parsed.data.body.partNumbers,
},
request,
})
return NextResponse.json({ data: { parts } })
} catch (error) {
const classified = uploadSessionErrorResponse(error)
const classified = knowledgeDocumentUploadErrorResponse(error)
if (classified) return classified
throw error
}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,16 +2,12 @@ import { type NextRequest, NextResponse } from 'next/server'
import { abortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils'
import { cancelKnowledgeDocumentUpload } from '@/lib/knowledge/application/upload-sessions'
import {
requireKnowledgeDocumentUploadAccess,
knowledgeDocumentUploadErrorResponse,
requireKnowledgeDocumentUploadActor,
} from '@/app/api/knowledge/[id]/documents/uploads/utils'
import {
abortKnowledgeDocumentUpload,
getOwnedKnowledgeDocumentUpload,
toV2KnowledgeDocumentUpload,
} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'

interface KnowledgeDocumentUploadRouteParams {
params: Promise<{ id: string; uploadId: string }>
Expand All@@ -25,24 +21,20 @@ export const DELETE = withRouteHandler(
if (!parsed.success) return parsed.response
const { id: knowledgeBaseId, uploadId } = parsed.data.params
const { workspaceId } = parsed.data.query
const access = await requireKnowledgeDocumentUploadAccess({
knowledgeBaseId,
workspaceId,
userId: actor.id,
})
if (access instanceof NextResponse) return access
try {
const upload = await getOwnedKnowledgeDocumentUpload({
knowledgeBaseId,
uploadId,
workspaceId,
userId: actor.id,
uploadToken: parsed.data.headers['upload-token'],
const aborted = await cancelKnowledgeDocumentUpload.execute({
principal: { kind: 'session', userId: actor.id, sessionId: actor.sessionId },
input: {
knowledgeBaseId,
assertedWorkspaceId: workspaceId,
uploadId,
uploadToken: parsed.data.headers['upload-token'],
},
request,
})
const aborted = await abortKnowledgeDocumentUpload(upload, knowledgeBaseId)
return NextResponse.json({ data: toV2KnowledgeDocumentUpload(aborted, null) })
} catch (error) {
const classified = uploadSessionErrorResponse(error)
const classified = knowledgeDocumentUploadErrorResponse(error)
if (classified) return classified
throw error
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
cancel: vi.fn(),
captureServerEvent: vi.fn(),
complete: vi.fn(),
parts: vi.fn(),
platformEvent: vi.fn(),
requireActor: vi.fn(),
}))

vi.mock('@/lib/knowledge/application/upload-sessions', () => ({
cancelKnowledgeDocumentUpload: { execute: mocks.cancel },
completeKnowledgeDocumentUpload: { execute: mocks.complete },
issueKnowledgeDocumentUploadParts: { execute: mocks.parts },
}))

vi.mock('@/lib/core/telemetry', () => ({
PlatformEvents: { knowledgeBaseDocumentsUploaded: mocks.platformEvent },
}))
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent }))
vi.mock('@/app/api/knowledge/[id]/documents/uploads/utils', () => ({
knowledgeDocumentUploadErrorResponse: vi.fn(() => null),
requireKnowledgeDocumentUploadActor: mocks.requireActor,
}))
vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({
toV2KnowledgeDocumentUpload: (_session: unknown, document: unknown) => ({
id: 'upload-1',
knowledgeBaseId: 'kb-1',
status: document ? 'completed' : 'aborted',
name: 'guide.pdf',
contentType: 'application/pdf',
size: 1024,
expiresAt: '2026-08-05T00:00:00.000Z',
error: null,
document,
}),
}))

import { POST as COMPLETE } from '@/app/api/knowledge/[id]/documents/uploads/[uploadId]/complete/route'
import { POST as PARTS } from '@/app/api/knowledge/[id]/documents/uploads/[uploadId]/parts/route'
import { DELETE as CANCEL } from '@/app/api/knowledge/[id]/documents/uploads/[uploadId]/route'

const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
const PRINCIPAL = { kind: 'session', userId: 'user-1', sessionId: 'session-1' }
const SESSION = { id: 'upload-1', knowledgeBaseId: 'kb-1' }
const DOCUMENT = {
id: 'upload-1',
knowledgeBaseId: 'kb-1',
filename: 'guide.pdf',
fileSize: 1024,
mimeType: 'application/pdf',
chunkCount: 0,
tokenCount: 0,
characterCount: 0,
enabled: true,
uploadedAt: new Date('2026-08-03T21:01:00.000Z'),
}

function routeContext() {
return { params: Promise.resolve({ id: 'kb-1', uploadId: 'upload-1' }) }
}

function controlUrl(suffix = '') {
return `http://localhost:3000/api/knowledge/kb-1/documents/uploads/upload-1${suffix}?workspaceId=${WORKSPACE_ID}`
}

describe('internal knowledge-document upload control routes', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.requireActor.mockResolvedValue({ id: 'user-1', sessionId: 'session-1' })
mocks.parts.mockResolvedValue({
parts: [
{
partNumber: 1,
url: 'https://storage.example/1',
headers: {},
expiresAt: '2026-08-04T21:00:00.000Z',
},
],
})
mocks.cancel.mockResolvedValue(SESSION)
mocks.complete.mockResolvedValue({
session: SESSION,
value: { document: DOCUMENT, created: true, knowledgeBaseName: 'Docs' },
alreadyCompleted: false,
workspaceId: WORKSPACE_ID,
knowledgeBaseId: 'kb-1',
})
})

it('delegates multipart part signing with the current session principal', async () => {
const request = new NextRequest(controlUrl('/parts'), {
method: 'POST',
headers: { 'content-type': 'application/json', 'upload-token': 'token' },
body: JSON.stringify({ partNumbers: [1] }),
})

const response = await PARTS(request, routeContext())

expect(response.status).toBe(200)
expect(mocks.parts).toHaveBeenCalledWith({
principal: PRINCIPAL,
input: {
knowledgeBaseId: 'kb-1',
assertedWorkspaceId: WORKSPACE_ID,
uploadId: 'upload-1',
uploadToken: 'token',
partNumbers: [1],
},
request,
})
})

it('delegates cancellation with the current session principal', async () => {
const request = new NextRequest(controlUrl(), {
method: 'DELETE',
headers: { 'upload-token': 'token' },
})

const response = await CANCEL(request, routeContext())

expect(response.status).toBe(200)
expect(mocks.cancel).toHaveBeenCalledWith({
principal: PRINCIPAL,
input: {
knowledgeBaseId: 'kb-1',
assertedWorkspaceId: WORKSPACE_ID,
uploadId: 'upload-1',
uploadToken: 'token',
},
request,
})
})

it('delegates completion and emits UI analytics only for a new document', async () => {
const request = new NextRequest(controlUrl('/complete'), {
method: 'POST',
headers: { 'upload-token': 'token' },
})

const response = await COMPLETE(request, routeContext())

expect(response.status).toBe(200)
expect(mocks.complete).toHaveBeenCalledWith({
principal: PRINCIPAL,
input: {
knowledgeBaseId: 'kb-1',
assertedWorkspaceId: WORKSPACE_ID,
uploadId: 'upload-1',
uploadToken: 'token',
source: 'ui',
},
request,
})
expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1)
expect(mocks.platformEvent).toHaveBeenCalledTimes(1)
})

it('does not duplicate UI analytics on an idempotent completion retry', async () => {
mocks.complete.mockResolvedValue({
session: SESSION,
value: { document: DOCUMENT, created: false, knowledgeBaseName: 'Docs' },
alreadyCompleted: true,
workspaceId: WORKSPACE_ID,
knowledgeBaseId: 'kb-1',
})
const request = new NextRequest(controlUrl('/complete'), {
method: 'POST',
headers: { 'upload-token': 'token' },
})

await COMPLETE(request, routeContext())

expect(mocks.captureServerEvent).not.toHaveBeenCalled()
expect(mocks.platformEvent).not.toHaveBeenCalled()
})
})
Loading
Loading