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