nexus/.planning/phases/25-file-system/25-01-PLAN.md
Nexus Dev d72c065fc7 docs(25-file-system): create phase plan — 4 plans in 3 waves
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>
2026-04-04 03:55:48 +00:00

12 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
25-file-system 01 execute 2
25-00
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
true
FILE-04
FILE-05
truths artifacts key_links
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
path provides exports
server/src/services/chat-files.ts chatFileService with CRUD + reference operations
chatFileService
path provides exports
server/src/routes/chat-files.ts Express router for file upload, list, download, reference
chatFileRoutes
from to via pattern
server/src/routes/chat-files.ts server/src/services/chat-files.ts chatFileService(db) call chatFileService
from to via pattern
server/src/routes/chat-files.ts server/src/storage/types.ts StorageService for putFile/getObject storage.putFile
from to via pattern
server/src/app.ts server/src/routes/chat-files.ts api.use(chatFileRoutes(db, storageService)) 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.

<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`