---
phase: 24-search-history-branching
plan: 01
type: execute
wave: 2
depends_on: ["24-00"]
files_modified:
  - server/src/services/chat.ts
  - server/src/routes/chat.ts
autonomous: true
requirements:
  - CHAT-07
  - CHAT-13
  - CHAT-14
  - HIST-04
  - HIST-09
  - HIST-10
  - HIST-11
  - PERF-04

must_haves:
  truths:
    - "searchMessages returns ranked results using tsvector FTS in under 500ms"
    - "toggleBookmark creates or removes a bookmark for a message"
    - "getBookmarks returns bookmarked messages with conversation titles"
    - "branchConversation creates child conversation with copied messages up to branch point"
    - "exportConversation returns Markdown or JSON file content with agent names resolved"
    - "listBranches returns child conversations for a parent"
  artifacts:
    - path: "server/src/services/chat.ts"
      provides: "searchMessages, toggleBookmark, getBookmarks, branchConversation, listBranches, exportConversation"
      contains: "searchMessages"
    - path: "server/src/routes/chat.ts"
      provides: "Search, bookmark, branch, export HTTP routes"
      contains: "messages/search"
  key_links:
    - from: "server/src/routes/chat.ts"
      to: "server/src/services/chat.ts"
      via: "svc.searchMessages, svc.toggleBookmark, svc.branchConversation, svc.exportConversation"
      pattern: "svc\\.searchMessages"
    - from: "server/src/services/chat.ts"
      to: "packages/db/src/schema/chat_message_bookmarks.ts"
      via: "import chatMessageBookmarks"
      pattern: "chatMessageBookmarks"
---

<objective>
Implement all server-side service methods and Express routes for search, bookmarks, branching, and export.

Purpose: Complete backend API so the UI plans can wire against real endpoints.
Output: Six new service methods and five new route handlers.
</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/STATE.md
@.planning/phases/24-search-history-branching/24-RESEARCH.md
@.planning/phases/24-search-history-branching/24-00-SUMMARY.md

@server/src/services/chat.ts
@server/src/routes/chat.ts
@packages/db/src/schema/chat_conversations.ts
@packages/db/src/schema/chat_messages.ts
@packages/db/src/schema/chat_message_bookmarks.ts
@packages/shared/src/types/chat.ts
@packages/shared/src/validators/chat.ts

<interfaces>
<!-- From packages/shared/src/types/chat.ts (after Plan 00): -->
ChatMessageSearchResult { messageId, conversationId, conversationTitle, content, role, agentId, createdAt, rank }
ChatMessageSearchResponse { items: ChatMessageSearchResult[] }
ChatBookmark { id, companyId, messageId, conversationId, createdAt }
ChatBookmarkWithMessage extends ChatBookmark { message: ChatMessage, conversationTitle }
ChatBookmarkListResponse { items: ChatBookmarkWithMessage[] }
ChatBookmarkToggleResponse { bookmarked: boolean }

<!-- From packages/shared/src/validators/chat.ts (after Plan 00): -->
searchMessagesSchema: z.object({ q: string.min(2).max(200), limit: coerce.number.optional() })
branchConversationSchema: z.object({ branchFromMessageId: string.uuid() })

<!-- From server/src/routes/chat.ts (existing pattern): -->
assertBoard(req) + assertCompanyAccess(req, companyId) guard on all company-scoped routes
chatService(db) factory pattern returning plain object
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Service methods — searchMessages, toggleBookmark, getBookmarks, branchConversation, listBranches, exportConversation</name>
  <files>server/src/services/chat.ts</files>
  <read_first>
    server/src/services/chat.ts,
    packages/db/src/schema/chat_messages.ts,
    packages/db/src/schema/chat_conversations.ts,
    packages/db/src/schema/chat_message_bookmarks.ts,
    packages/db/src/schema/index.ts,
    packages/shared/src/types/chat.ts
  </read_first>
  <action>
    Add these methods to the `chatService(db)` return object. Import `sql, asc, lte` from drizzle-orm (add to existing import). Import `chatMessageBookmarks` from `@paperclipai/db`. Import `agents` from `@paperclipai/db` for export agent name resolution.

    **searchMessages(companyId, query, opts: { limit?: number }):**
    - Use `sql` template for `plainto_tsquery('english', ${query})` and `ts_rank`
    - Join chatMessages with chatConversations on conversationId + companyId + isNull(deletedAt)
    - WHERE: `sql\`"chat_messages"."content_search" @@ plainto_tsquery('english', ${query})\``
    - ORDER BY: `desc(sql\`ts_rank(...)\`)`
    - Limit: `Math.min(opts.limit ?? 20, 50)`
    - Return `{ items }` matching ChatMessageSearchResponse shape
    - If query.trim() is empty, return `{ items: [] }` early

    **toggleBookmark(companyId, messageId, conversationId):**
    - Check if bookmark exists: `SELECT id FROM chat_message_bookmarks WHERE company_id = ? AND message_id = ?`
    - If exists: DELETE and return `{ bookmarked: false }`
    - If not: INSERT and return `{ bookmarked: true }`
    - Use a single transaction for atomicity

    **getBookmarks(companyId, opts: { conversationId?: string }):**
    - Select from chatMessageBookmarks JOIN chatMessages JOIN chatConversations
    - Filter by companyId, optionally by conversationId
    - Order by chatMessageBookmarks.createdAt desc
    - Return `{ items }` matching ChatBookmarkListResponse shape
    - Each item includes the full message object and conversationTitle

    **branchConversation(parentConversationId, branchFromMessageId, companyId):**
    - Follow Pattern 2 from RESEARCH.md exactly
    - Get branchMsg createdAt, then get all messages up to that point ordered by asc(createdAt)
    - Create new conversation with parentConversationId and branchFromMessageId set
    - Copy messages into new conversation (spread rest, exclude id and conversationId)
    - Return the new conversation
    - Throw notFound if branchFromMessageId does not exist

    **listBranches(conversationId):**
    - SELECT from chatConversations WHERE parentConversationId = conversationId AND deletedAt IS NULL
    - Order by createdAt desc
    - Return array of conversations

    **exportConversation(conversationId, format: "markdown" | "json"):**
    - Get conversation metadata (title, createdAt)
    - Get ALL messages ordered by asc(createdAt) — no pagination
    - Join with agents table to resolve agentId -> agent name (LEFT JOIN agents ON chatMessages.agentId = agents.id)
    - **Markdown format:** Build string: `# {title}\nExported: {date}\n\n---\n\n` then for each message: `**{agentName || role}** ({timestamp})\n{content}\n\n---\n\n`
    - For user messages, use "You" as the speaker name
    - **JSON format:** Return `JSON.stringify({ conversation: { id, title, createdAt }, messages }, null, 2)`
    - Return `{ content: string, filename: string }` — filename is `{title-slug}-{date}.md` or `.json`
    - Sanitize title for filename: lowercase, replace spaces with hyphens, remove special chars, truncate to 50 chars
    - Add code comment: `// Note: loads all messages in memory. For very large conversations, consider streaming in future.`
  </action>
  <verify>
    <automated>cd /opt/nexus && pnpm --filter @paperclipai/server build 2>&1 | tail -10</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "searchMessages" server/src/services/chat.ts
    - grep -q "toggleBookmark" server/src/services/chat.ts
    - grep -q "getBookmarks" server/src/services/chat.ts
    - grep -q "branchConversation" server/src/services/chat.ts
    - grep -q "listBranches" server/src/services/chat.ts
    - grep -q "exportConversation" server/src/services/chat.ts
    - grep -q "plainto_tsquery" server/src/services/chat.ts
    - grep -q "ts_rank" server/src/services/chat.ts
    - grep -q "chatMessageBookmarks" server/src/services/chat.ts
  </acceptance_criteria>
  <done>Six service methods implemented: searchMessages uses tsvector FTS with ts_rank ordering, toggleBookmark does insert-or-delete, getBookmarks joins with messages and conversations, branchConversation copies messages up to branch point, listBranches queries child conversations, exportConversation resolves agent names and produces Markdown or JSON.</done>
</task>

<task type="auto">
  <name>Task 2: Express routes — search, bookmarks, branch, export</name>
  <files>server/src/routes/chat.ts</files>
  <read_first>
    server/src/routes/chat.ts,
    packages/shared/src/validators/chat.ts
  </read_first>
  <action>
    Add imports for `searchMessagesSchema` and `branchConversationSchema` from `@paperclipai/shared`.

    **GET /companies/:companyId/messages/search:**
    - `assertBoard(req)` + `assertCompanyAccess(req, req.params.companyId!)`
    - Parse query params with `searchMessagesSchema.parse({ q: req.query.q, limit: req.query.limit })`
    - Call `svc.searchMessages(companyId, parsed.q, { limit: parsed.limit })`
    - Wrap parse in try/catch — on ZodError return 400 with `{ error: "Query must be at least 2 characters" }`
    - Return 200 with results

    **POST /conversations/:id/bookmarks:**
    - `assertBoard(req)`
    - Get conversationId from `req.params.id`
    - Get messageId from `req.body.messageId` (validate with `z.string().uuid()`)
    - Get companyId by first calling `svc.getConversation(conversationId)` to obtain `conversation.companyId`
    - Call `svc.toggleBookmark(companyId, messageId, conversationId)`
    - Return 200 with `{ bookmarked }` response

    **GET /companies/:companyId/bookmarks:**
    - `assertBoard(req)` + `assertCompanyAccess(req, req.params.companyId!)`
    - Optional query param `conversationId`
    - Call `svc.getBookmarks(companyId, { conversationId })`
    - Return 200 with results

    **POST /conversations/:id/branch:**
    - `assertBoard(req)`
    - Parse body with `branchConversationSchema.parse(req.body)`
    - Get conversation to extract companyId: `const conv = await svc.getConversation(req.params.id!)`
    - Call `svc.branchConversation(req.params.id!, parsed.branchFromMessageId, conv.companyId)`
    - Return 201 with new conversation

    **GET /conversations/:id/branches:**
    - `assertBoard(req)`
    - Call `svc.listBranches(req.params.id!)`
    - Return 200 with `{ items }` array

    **GET /conversations/:id/export:**
    - `assertBoard(req)`
    - Parse format: `const format = req.query.format === "json" ? "json" : "markdown"`
    - Call `svc.exportConversation(req.params.id!, format)`
    - Set headers: `Content-Disposition: attachment; filename="{filename}"`, `Content-Type: {mime}`
    - `res.send(content)`
  </action>
  <verify>
    <automated>cd /opt/nexus && pnpm --filter @paperclipai/server build 2>&1 | tail -10</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "messages/search" server/src/routes/chat.ts
    - grep -q "bookmarks" server/src/routes/chat.ts
    - grep -q "branch" server/src/routes/chat.ts
    - grep -q "export" server/src/routes/chat.ts
    - grep -q "searchMessagesSchema" server/src/routes/chat.ts
    - grep -q "branchConversationSchema" server/src/routes/chat.ts
    - grep -q "Content-Disposition" server/src/routes/chat.ts
  </acceptance_criteria>
  <done>Six route handlers implemented: message search (GET), bookmark toggle (POST), bookmark list (GET), branch create (POST), branch list (GET), export download (GET). All routes use assertBoard guard. Search validates query length. Export sets file download headers.</done>
</task>

</tasks>

<verification>
- `pnpm --filter @paperclipai/server build` passes
- Search route uses searchMessagesSchema validation
- Branch route uses branchConversationSchema validation
- Export route sets Content-Disposition header
- All routes use assertBoard(req) guard pattern
</verification>

<success_criteria>
Server builds cleanly. Six service methods and six route handlers exist. Search uses tsvector FTS. Export resolves agent names. Branch copies messages. Bookmark toggles insert/delete.
</success_criteria>

<output>
After completion, create `.planning/phases/24-search-history-branching/24-01-SUMMARY.md`
</output>
