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>
297 lines
12 KiB
Markdown
297 lines
12 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<interfaces>
|
|
<!-- Key types the executor needs from Plan 00 outputs -->
|
|
|
|
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<PutFileResult>;
|
|
getObject(companyId: string, objectKey: string): Promise<GetObjectResult>;
|
|
}
|
|
```
|
|
</interfaces>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create chatFileService with DB operations</name>
|
|
<files>
|
|
server/src/services/chat-files.ts,
|
|
server/src/__tests__/chat-file-service.test.ts
|
|
</files>
|
|
<read_first>
|
|
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
|
|
</read_first>
|
|
<action>
|
|
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
|
|
</action>
|
|
<verify>
|
|
<automated>cd /opt/nexus && npx vitest run server/src/__tests__/chat-file-service.test.ts --reporter=verbose 2>&1 | tail -20</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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
|
|
</acceptance_criteria>
|
|
<done>
|
|
chatFileService handles all DB operations for file CRUD, references, and message attachment.
|
|
Tests verify service methods work correctly with mocked DB.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create chatFileRoutes and wire into app.ts</name>
|
|
<files>
|
|
server/src/routes/chat-files.ts,
|
|
server/src/routes/index.ts,
|
|
server/src/app.ts,
|
|
server/src/__tests__/chat-file-routes.test.ts
|
|
</files>
|
|
<read_first>
|
|
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
|
|
</read_first>
|
|
<action>
|
|
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
|
|
</action>
|
|
<verify>
|
|
<automated>cd /opt/nexus && npx vitest run server/src/__tests__/chat-file-routes.test.ts --reporter=verbose 2>&1 | tail -20</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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
|
|
</acceptance_criteria>
|
|
<done>
|
|
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.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/25-file-system/25-01-SUMMARY.md`
|
|
</output>
|