--- phase: 25-file-system plan: 01 type: execute wave: 2 depends_on: ["25-00"] files_modified: - server/src/services/chat-files.ts - server/src/routes/chat-files.ts - server/src/routes/index.ts - server/src/app.ts - server/src/__tests__/chat-file-service.test.ts - server/src/__tests__/chat-file-routes.test.ts autonomous: true requirements: - FILE-04 - FILE-05 must_haves: truths: - "User can upload a file via POST /api/conversations/:id/files and get back a ChatFile with contentPath" - "Uploaded file is stored on disk via StorageService and metadata persisted in chat_files table" - "File content is served via GET /api/files/:fileId/content with correct MIME type" - "Files for a conversation are listed via GET /api/conversations/:id/files" - "Cross-conversation file reference is created via POST /api/files/:fileId/references" artifacts: - path: "server/src/services/chat-files.ts" provides: "chatFileService with CRUD + reference operations" exports: ["chatFileService"] - path: "server/src/routes/chat-files.ts" provides: "Express router for file upload, list, download, reference" exports: ["chatFileRoutes"] key_links: - from: "server/src/routes/chat-files.ts" to: "server/src/services/chat-files.ts" via: "chatFileService(db) call" pattern: "chatFileService" - from: "server/src/routes/chat-files.ts" to: "server/src/storage/types.ts" via: "StorageService for putFile/getObject" pattern: "storage\\.putFile" - from: "server/src/app.ts" to: "server/src/routes/chat-files.ts" via: "api.use(chatFileRoutes(db, storageService))" pattern: "chatFileRoutes" --- Implement the server-side file service and REST routes for uploading, listing, downloading, and referencing chat files. Purpose: Provide the backend that the UI (Plans 02+03) will call for file operations. Output: chatFileService with DB operations, chatFileRoutes with Express endpoints, wired into app.ts. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/25-file-system/25-RESEARCH.md @.planning/phases/25-file-system/25-00-SUMMARY.md @server/src/routes/assets.ts @server/src/routes/chat.ts @server/src/services/chat.ts @server/src/services/assets.ts @server/src/storage/service.ts @server/src/storage/types.ts @server/src/attachment-types.ts @server/src/app.ts @server/src/__tests__/chat-routes.test.ts @packages/db/src/schema/chat_files.ts @packages/db/src/schema/chat_file_references.ts @packages/shared/src/types/chat.ts @packages/shared/src/validators/chat.ts From packages/db/src/schema/chat_files.ts (created in Plan 00): ```typescript export const chatFiles = pgTable("chat_files", { id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull(), conversationId: uuid("conversation_id"), messageId: uuid("message_id"), filename: text("filename").notNull(), originalFilename: text("original_filename").notNull(), mimeType: text("mime_type").notNull(), sizeBytes: integer("size_bytes").notNull(), objectKey: text("object_key").notNull(), sha256: text("sha256").notNull(), source: text("source").notNull(), category: text("category"), projectId: uuid("project_id"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }); ``` From packages/shared/src/validators/chat.ts (created in Plan 00): ```typescript export const uploadChatFileSchema = z.object({ conversationId: z.string().uuid().optional(), messageId: z.string().uuid().optional(), source: z.enum(["user_upload", "agent_generated"]).default("user_upload"), projectId: z.string().uuid().optional(), }); export const createFileReferenceSchema = z.object({ fileId: z.string().uuid(), messageId: z.string().uuid().optional(), }); ``` From server/src/storage/types.ts: ```typescript export interface StorageService { provider: StorageProviderId; putFile(input: PutFileInput): Promise; getObject(companyId: string, objectKey: string): Promise; } ``` Task 1: Create chatFileService with DB operations server/src/services/chat-files.ts, server/src/__tests__/chat-file-service.test.ts server/src/services/assets.ts, server/src/services/chat.ts, packages/db/src/schema/chat_files.ts, packages/db/src/schema/chat_file_references.ts, server/src/__tests__/chat-routes.test.ts Create `server/src/services/chat-files.ts` exporting a `chatFileService(db: Db)` function that returns an object with these methods: 1. **create(companyId, data)** — Insert a row into chat_files. `data` includes: conversationId, messageId, filename, originalFilename, mimeType, sizeBytes, objectKey, sha256, source, category, projectId. Returns the inserted row. 2. **getById(id)** — Select a chat_files row by id. Returns row or null. 3. **listByConversation(conversationId, opts?)** — Select chat_files where conversationId matches, ordered by createdAt desc. Support optional limit (default 50). 4. **listByMessage(messageId)** — Select chat_files where messageId matches, ordered by createdAt asc. 5. **createReference(data)** — Insert a row into chat_file_references with fileId, conversationId, messageId. Returns the inserted row. 6. **listReferences(fileId)** — Select chat_file_references where fileId matches. 7. **attachToMessage(fileId, messageId)** — Update chat_files set messageId where id = fileId. Returns updated row. Helper: `deriveCategory(mimeType: string): string` — returns "image" for image/*, "code" for text/javascript, text/typescript, text/css, text/html, application/json, text/x-python, text/x-java, etc., "document" for application/pdf, text/plain, text/markdown, text/csv, "other" for everything else. Use Drizzle `eq`, `desc` from drizzle-orm. Import `chatFiles`, `chatFileReferences` from `@paperclipai/db`. Update `server/src/__tests__/chat-file-service.test.ts` — replace todo stubs with real tests using vi.mock for the db. Follow the same mock pattern as chat-routes.test.ts: - Mock the db select/insert/update calls - Test create returns inserted row - Test getById returns null when not found - Test deriveCategory for image, code, document, other mime types cd /opt/nexus && npx vitest run server/src/__tests__/chat-file-service.test.ts --reporter=verbose 2>&1 | tail -20 - grep "chatFileService" server/src/services/chat-files.ts returns a match - grep "deriveCategory" server/src/services/chat-files.ts returns a match - grep "chatFiles" server/src/services/chat-files.ts returns a match - grep "chatFileReferences" server/src/services/chat-files.ts returns a match - grep "attachToMessage" server/src/services/chat-files.ts returns a match - vitest chat-file-service tests pass chatFileService handles all DB operations for file CRUD, references, and message attachment. Tests verify service methods work correctly with mocked DB. Task 2: Create chatFileRoutes and wire into app.ts server/src/routes/chat-files.ts, server/src/routes/index.ts, server/src/app.ts, server/src/__tests__/chat-file-routes.test.ts server/src/routes/assets.ts, server/src/routes/chat.ts, server/src/app.ts, server/src/routes/index.ts, server/src/attachment-types.ts, server/src/services/chat-files.ts Create `server/src/routes/chat-files.ts` exporting `chatFileRoutes(db: Db, storage: StorageService): Router`: Use multer v2 memory storage (same pattern as assets.ts): ```typescript const fileUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 }, }); ``` Endpoints: 1. **POST /conversations/:id/files** — Upload a file to a conversation - Use the same `runSingleFileUpload` pattern from assets.ts - Parse body metadata with `uploadChatFileSchema` (from req.body after multer) - Validate content type with `isAllowedContentType` - Call `storage.putFile({ companyId, namespace: "chat-files", originalFilename, contentType, body })` - Resolve companyId by calling chatService(db).getConversation(conversationId) - Call `chatFileService.create(companyId, { ...metadata, objectKey, sha256, sizeBytes, category: deriveCategory(mimeType) })` - Return 201 with `{ file: , contentPath: "/api/files/${file.id}/content" }` 2. **GET /conversations/:id/files** — List files for a conversation - assertBoard(req) - Call `chatFileService.listByConversation(conversationId)` - Return `{ items: files }` 3. **GET /files/:fileId/content** — Serve file content (download/preview) - assertBoard(req) - Get file by id, assert company access - Pipe `storage.getObject(file.companyId, file.objectKey).stream` to response - Set Content-Type, Content-Length, Content-Disposition (inline for images, attachment for others), Cache-Control, X-Content-Type-Options: nosniff 4. **POST /files/:fileId/references** — Create cross-conversation reference - assertBoard(req) - Parse body with createFileReferenceSchema - Get file by id, assert company access - Create reference row - Return 201 with reference 5. **PATCH /files/:fileId** — Attach file to a message (set messageId) - assertBoard(req) - Parse { messageId } from body - Call chatFileService.attachToMessage(fileId, messageId) - Return updated file Wire into app: - Add `export { chatFileRoutes } from "./chat-files.js";` to `server/src/routes/index.ts` - In `server/src/app.ts`, import `chatFileRoutes` and add `api.use(chatFileRoutes(db, opts.storageService));` right after the `chatRoutes(db)` line (around line 160) Update `server/src/__tests__/chat-file-routes.test.ts` — replace todo stubs with tests: - Mock chatFileService and storage - Test POST /conversations/:id/files returns 201 with file - Test GET /files/:fileId/content returns streamed content - Test 400 on missing file field - Test 422 on unsupported content type - Follow the same createApp() pattern from chat-routes.test.ts but pass mock storage cd /opt/nexus && npx vitest run server/src/__tests__/chat-file-routes.test.ts --reporter=verbose 2>&1 | tail -20 - grep "chatFileRoutes" server/src/routes/chat-files.ts returns a match - grep "chatFileRoutes" server/src/routes/index.ts returns a match - grep "chatFileRoutes" server/src/app.ts returns a match - grep "POST.*files" server/src/routes/chat-files.ts returns a match - grep "content" server/src/routes/chat-files.ts returns a match (content download route) - grep "multer" server/src/routes/chat-files.ts returns a match - grep "storage.putFile" server/src/routes/chat-files.ts returns a match - vitest chat-file-routes tests pass File upload, list, download, and reference endpoints work. Routes are wired into Express app. Tests verify upload returns 201, content is streamed, validation rejects bad input. - `POST /api/conversations/:id/files` with multipart form data stores file and creates DB record - `GET /api/conversations/:id/files` returns list of files for conversation - `GET /api/files/:fileId/content` streams file content with correct MIME type - `POST /api/files/:fileId/references` creates cross-conversation reference - Routes are mounted in app.ts - All tests pass Complete server-side file system: upload via multipart, download via streaming, list per conversation, cross-conversation references. FILE-04 (dual scoping via projectId) and FILE-05 (upload from chat) backend is ready for the UI to consume. After completion, create `.planning/phases/25-file-system/25-01-SUMMARY.md`