Plans cover FILE-01 through FILE-06: DB schema + shared types (wave 1), server file service + routes and UI file upload (wave 2, parallel), file preview components + full wiring (wave 3). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 25-file-system | 01 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.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):
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):
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:
export interface StorageService {
provider: StorageProviderId;
putFile(input: PutFileInput): Promise<PutFileResult>;
getObject(companyId: string, objectKey: string): Promise<GetObjectResult>;
}
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: <ChatFile>, 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
<success_criteria> 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. </success_criteria>
After completion, create `.planning/phases/25-file-system/25-01-SUMMARY.md`