From 5483991d3896522b76ca7e71fbc218429ece6b81 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 23:26:20 +0000 Subject: [PATCH] feat(25-01): create chatFileRoutes and wire into app.ts - Create chatFileRoutes with upload, list, content, references, attach endpoints - Wire into app.ts after assetRoutes, export from routes/index.ts - Real tests replacing todo stubs in chat-file-routes.test.ts --- server/src/__tests__/chat-file-routes.test.ts | 272 +++++++++++++++++- server/src/app.ts | 2 + server/src/routes/chat-files.ts | 204 +++++++++++++ server/src/routes/index.ts | 1 + 4 files changed, 472 insertions(+), 7 deletions(-) create mode 100644 server/src/routes/chat-files.ts diff --git a/server/src/__tests__/chat-file-routes.test.ts b/server/src/__tests__/chat-file-routes.test.ts index e01aa30b..562cb51e 100644 --- a/server/src/__tests__/chat-file-routes.test.ts +++ b/server/src/__tests__/chat-file-routes.test.ts @@ -1,10 +1,268 @@ -import { describe, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import express from "express"; +import request from "supertest"; +import { Readable } from "node:stream"; +import { MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; +import { chatFileRoutes } from "../routes/chat-files.js"; +import type { StorageService } from "../storage/types.js"; + +const { mockCreate, mockGetById, mockListByConversation, mockCreateReference, mockAttachToMessage } = vi.hoisted( + () => ({ + mockCreate: vi.fn(), + mockGetById: vi.fn(), + mockListByConversation: vi.fn(), + mockCreateReference: vi.fn(), + mockAttachToMessage: vi.fn(), + }), +); + +const { mockGetConversation } = vi.hoisted(() => ({ + mockGetConversation: vi.fn(), +})); + +vi.mock("../services/chat-files.js", () => ({ + chatFileService: vi.fn(() => ({ + create: mockCreate, + getById: mockGetById, + listByConversation: mockListByConversation, + createReference: mockCreateReference, + attachToMessage: mockAttachToMessage, + })), + deriveCategory: (mimeType: string) => { + if (mimeType.startsWith("image/")) return "image"; + if (mimeType === "application/pdf" || mimeType.startsWith("text/")) return "document"; + return "other"; + }, +})); + +vi.mock("../services/chat.js", () => ({ + chatService: vi.fn(() => ({ + getConversation: mockGetConversation, + })), +})); + +function createStorageService(contentType = "image/png"): StorageService { + return { + provider: "local_disk" as const, + putFile: vi.fn(async (input: { companyId: string; namespace: string; originalFilename: string | null; contentType: string; body: Buffer }) => ({ + provider: "local_disk" as const, + objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`, + contentType: contentType || input.contentType, + byteSize: input.body.length, + sha256: "sha256-test", + originalFilename: input.originalFilename, + })), + getObject: vi.fn(), + headObject: vi.fn(), + deleteObject: vi.fn(), + }; +} + +function createApp(storage: StorageService) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + source: "local_implicit", + userId: "user-1", + }; + next(); + }); + app.use("/api", chatFileRoutes({} as any, storage)); + return app; +} + +const FILE_ID = "00000000-0000-0000-0000-000000000001"; +const CONV_ID = "00000000-0000-0000-0000-000000000002"; + +function makeFile() { + return { + id: FILE_ID, + companyId: "company-1", + conversationId: CONV_ID, + messageId: null, + filename: "test.png", + originalFilename: "test.png", + mimeType: "image/png", + sizeBytes: 40, + objectKey: "chat-files/test.png", + sha256: "sha256-test", + source: "user_upload", + category: "image", + projectId: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }; +} describe("chatFileRoutes", () => { - it.todo("POST /conversations/:id/files uploads a file and returns 201"); - it.todo("GET /conversations/:id/files lists files for conversation"); - it.todo("GET /files/:fileId/content serves file content"); - it.todo("POST /files/:fileId/references creates a cross-conversation reference"); - it.todo("rejects upload when file exceeds size limit"); - it.todo("rejects upload when content type is not allowed"); + beforeEach(() => { + vi.clearAllMocks(); + mockGetConversation.mockResolvedValue({ id: "conv-1", companyId: "company-1" }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("POST /api/conversations/:id/files", () => { + it("uploads a file and returns 201 with file and contentPath", async () => { + const storage = createStorageService("image/png"); + const app = createApp(storage); + const mockFile = makeFile(); + mockCreate.mockResolvedValue(mockFile); + + const res = await request(app) + .post("/api/conversations/conv-1/files") + .attach("file", Buffer.from("png-data"), { filename: "test.png", contentType: "image/png" }); + + expect(res.status).toBe(201); + expect(res.body.contentPath).toBe(`/api/files/${FILE_ID}/content`); + expect(res.body.file.id).toBe(FILE_ID); + expect(mockCreate).toHaveBeenCalledTimes(1); + }); + + it("returns 400 when no file field is provided", async () => { + const storage = createStorageService(); + const app = createApp(storage); + + const res = await request(app) + .post("/api/conversations/conv-1/files") + .field("source", "user_upload"); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("Missing file field"); + }); + + it("returns 422 when content type is not allowed", async () => { + const storage = createStorageService(); + const app = createApp(storage); + + const res = await request(app) + .post("/api/conversations/conv-1/files") + .attach("file", Buffer.from("video-data"), { filename: "video.mp4", contentType: "video/mp4" }); + + expect(res.status).toBe(422); + expect(res.body.error).toContain("Unsupported file type"); + }); + + it("returns 422 when file exceeds size limit", async () => { + const storage = createStorageService(); + const app = createApp(storage); + + const oversized = Buffer.alloc(MAX_ATTACHMENT_BYTES + 1); + + const res = await request(app) + .post("/api/conversations/conv-1/files") + .attach("file", oversized, { filename: "big.png", contentType: "image/png" }); + + expect(res.status).toBe(422); + }); + }); + + describe("GET /api/conversations/:id/files", () => { + it("returns list of files for the conversation", async () => { + const storage = createStorageService(); + const app = createApp(storage); + const files = [makeFile()]; + mockListByConversation.mockResolvedValue(files); + + const res = await request(app).get("/api/conversations/conv-1/files"); + + expect(res.status).toBe(200); + expect(res.body.items).toHaveLength(1); + expect(res.body.items[0].id).toBe(FILE_ID); + }); + }); + + describe("GET /api/files/:fileId/content", () => { + it("streams file content with correct MIME type", async () => { + const storage = createStorageService("image/png"); + const app = createApp(storage); + const mockFile = makeFile(); + mockGetById.mockResolvedValue(mockFile); + + const imageBytes = Buffer.from("image-bytes"); + (storage.getObject as ReturnType).mockResolvedValue({ + stream: Readable.from([imageBytes]), + contentType: "image/png", + contentLength: imageBytes.length, + }); + + const res = await request(app).get(`/api/files/${FILE_ID}/content`); + + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toContain("image/png"); + expect(res.headers["x-content-type-options"]).toBe("nosniff"); + }); + + it("returns 404 when file not found", async () => { + const storage = createStorageService(); + const app = createApp(storage); + mockGetById.mockResolvedValue(null); + + const res = await request(app).get("/api/files/nonexistent/content"); + + expect(res.status).toBe(404); + expect(res.body.error).toContain("not found"); + }); + }); + + describe("POST /api/files/:fileId/references", () => { + it("creates a cross-conversation reference and returns 201", async () => { + const storage = createStorageService(); + const app = createApp(storage); + const mockFile = makeFile(); + mockGetById.mockResolvedValue(mockFile); + + const reference = { + id: "00000000-0000-0000-0000-000000000099", + fileId: FILE_ID, + conversationId: CONV_ID, + messageId: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }; + mockCreateReference.mockResolvedValue(reference); + + const res = await request(app) + .post(`/api/files/${FILE_ID}/references`) + .send({ messageId: "00000000-0000-0000-0000-000000000001" }); + + expect(res.status).toBe(201); + expect(res.body.id).toBe("00000000-0000-0000-0000-000000000099"); + }); + }); + + describe("PATCH /api/files/:fileId", () => { + it("attaches file to a message and returns updated file", async () => { + const storage = createStorageService(); + const app = createApp(storage); + const mockFile = makeFile(); + mockGetById.mockResolvedValue(mockFile); + + const updated = { ...mockFile, messageId: "msg-1" }; + mockAttachToMessage.mockResolvedValue(updated); + + const res = await request(app) + .patch(`/api/files/${FILE_ID}`) + .send({ messageId: "msg-1" }); + + expect(res.status).toBe(200); + expect(res.body.messageId).toBe("msg-1"); + }); + + it("returns 400 when messageId is missing", async () => { + const storage = createStorageService(); + const app = createApp(storage); + const mockFile = makeFile(); + mockGetById.mockResolvedValue(mockFile); + + const res = await request(app) + .patch(`/api/files/${FILE_ID}`) + .send({}); + + expect(res.status).toBe(400); + }); + }); }); diff --git a/server/src/app.ts b/server/src/app.ts index 28dff0b4..d2b6e465 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -30,6 +30,7 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; import { llmRoutes } from "./routes/llms.js"; import { assetRoutes } from "./routes/assets.js"; +import { chatFileRoutes } from "./routes/chat-files.js"; import { accessRoutes } from "./routes/access.js"; import { pluginRoutes } from "./routes/plugins.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; @@ -148,6 +149,7 @@ export async function createApp( api.use(skillGroupRoutes(db)); api.use(agentRoutes(db)); api.use(assetRoutes(db, opts.storageService)); + api.use(chatFileRoutes(db, opts.storageService)); api.use(projectRoutes(db)); api.use(issueRoutes(db, opts.storageService)); api.use(routineRoutes(db)); diff --git a/server/src/routes/chat-files.ts b/server/src/routes/chat-files.ts new file mode 100644 index 00000000..0a75e6cb --- /dev/null +++ b/server/src/routes/chat-files.ts @@ -0,0 +1,204 @@ +import { Router, type Request, type Response } from "express"; +import multer from "multer"; +import type { Db } from "@paperclipai/db"; +import { uploadChatFileSchema, createFileReferenceSchema } from "@paperclipai/shared"; +import type { StorageService } from "../storage/types.js"; +import { chatFileService, deriveCategory } from "../services/chat-files.js"; +import { chatService } from "../services/chat.js"; +import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; +import { assertBoard, assertCompanyAccess } from "./authz.js"; + +const fileUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 }, +}); + +async function runSingleFileUpload( + upload: ReturnType, + req: Request, + res: Response, +) { + await new Promise((resolve, reject) => { + upload.single("file")(req, res, (err: unknown) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +export function chatFileRoutes(db: Db, storage: StorageService) { + const router = Router(); + const fileSvc = chatFileService(db); + const convSvc = chatService(db); + + // POST /conversations/:id/files — Upload a file to a conversation + router.post("/conversations/:id/files", async (req, res) => { + assertBoard(req); + + const conversationId = req.params.id as string; + + try { + await runSingleFileUpload(fileUpload, req, res); + } catch (err) { + if (err instanceof multer.MulterError) { + if (err.code === "LIMIT_FILE_SIZE") { + res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` }); + return; + } + res.status(400).json({ error: err.message }); + return; + } + throw err; + } + + const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string; size: number } }).file; + if (!file) { + res.status(400).json({ error: "Missing file field 'file'" }); + return; + } + + const contentType = (file.mimetype || "").toLowerCase(); + if (!isAllowedContentType(contentType)) { + res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` }); + return; + } + + const parsedMeta = uploadChatFileSchema.safeParse(req.body ?? {}); + if (!parsedMeta.success) { + res.status(400).json({ error: "Invalid metadata", details: parsedMeta.error.issues }); + return; + } + + // Resolve companyId from conversation + const conversation = await convSvc.getConversation(conversationId); + assertCompanyAccess(req, conversation.companyId); + + const stored = await storage.putFile({ + companyId: conversation.companyId, + namespace: "chat-files", + originalFilename: file.originalname || null, + contentType, + body: file.buffer, + }); + + const chatFile = await fileSvc.create(conversation.companyId, { + conversationId, + messageId: parsedMeta.data.messageId ?? null, + filename: stored.originalFilename ?? file.originalname ?? "upload", + originalFilename: file.originalname ?? "upload", + mimeType: contentType, + sizeBytes: stored.byteSize, + objectKey: stored.objectKey, + sha256: stored.sha256, + source: parsedMeta.data.source, + category: deriveCategory(contentType), + projectId: parsedMeta.data.projectId ?? null, + }); + + res.status(201).json({ + file: chatFile, + contentPath: `/api/files/${chatFile.id}/content`, + }); + }); + + // GET /conversations/:id/files — List files for a conversation + router.get("/conversations/:id/files", async (req, res) => { + assertBoard(req); + + const conversationId = req.params.id as string; + const conversation = await convSvc.getConversation(conversationId); + assertCompanyAccess(req, conversation.companyId); + + const files = await fileSvc.listByConversation(conversationId); + res.json({ items: files }); + }); + + // GET /files/:fileId/content — Serve file content (download/preview) + router.get("/files/:fileId/content", async (req, res, next) => { + assertBoard(req); + + const fileId = req.params.fileId as string; + const chatFile = await fileSvc.getById(fileId); + if (!chatFile) { + res.status(404).json({ error: "File not found" }); + return; + } + assertCompanyAccess(req, chatFile.companyId); + + const object = await storage.getObject(chatFile.companyId, chatFile.objectKey); + const responseContentType = chatFile.mimeType || object.contentType || "application/octet-stream"; + + res.setHeader("Content-Type", responseContentType); + const contentLength = object.contentLength ?? chatFile.sizeBytes; + if (contentLength) { + res.setHeader("Content-Length", String(contentLength)); + } + res.setHeader("Cache-Control", "private, max-age=60"); + res.setHeader("X-Content-Type-Options", "nosniff"); + + const isImage = responseContentType.startsWith("image/"); + const filename = chatFile.originalFilename ?? chatFile.filename ?? "file"; + const sanitizedFilename = filename.replaceAll('"', ""); + if (isImage) { + res.setHeader("Content-Disposition", `inline; filename="${sanitizedFilename}"`); + } else { + res.setHeader("Content-Disposition", `attachment; filename="${sanitizedFilename}"`); + } + + object.stream.on("error", (err) => { + next(err); + }); + object.stream.pipe(res); + }); + + // POST /files/:fileId/references — Create cross-conversation reference + router.post("/files/:fileId/references", async (req, res) => { + assertBoard(req); + + const fileId = req.params.fileId as string; + const chatFile = await fileSvc.getById(fileId); + if (!chatFile) { + res.status(404).json({ error: "File not found" }); + return; + } + assertCompanyAccess(req, chatFile.companyId); + + const parsed = createFileReferenceSchema.safeParse({ fileId, ...(req.body ?? {}) }); + if (!parsed.success) { + res.status(400).json({ error: "Invalid reference data", details: parsed.error.issues }); + return; + } + + const reference = await fileSvc.createReference({ + fileId, + conversationId: chatFile.conversationId ?? "", + messageId: parsed.data.messageId, + }); + + res.status(201).json(reference); + }); + + // PATCH /files/:fileId — Attach file to a message (set messageId) + router.patch("/files/:fileId", async (req, res) => { + assertBoard(req); + + const fileId = req.params.fileId as string; + const chatFile = await fileSvc.getById(fileId); + if (!chatFile) { + res.status(404).json({ error: "File not found" }); + return; + } + assertCompanyAccess(req, chatFile.companyId); + + const { messageId } = req.body ?? {}; + if (!messageId || typeof messageId !== "string") { + res.status(400).json({ error: "messageId is required" }); + return; + } + + const updated = await fileSvc.attachToMessage(fileId, messageId); + res.json(updated); + }); + + return router; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index dd9c0b54..a4430f00 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -15,3 +15,4 @@ export { sidebarBadgeRoutes } from "./sidebar-badges.js"; export { llmRoutes } from "./llms.js"; export { accessRoutes } from "./access.js"; export { instanceSettingsRoutes } from "./instance-settings.js"; +export { chatFileRoutes } from "./chat-files.js";