From 505f5e22628efce92748ce45f094dadcc0c81ad9 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 22:31:59 +0000 Subject: [PATCH] feat(24-01): add search, bookmark, branch, and export Express route handlers - GET /companies/:companyId/messages/search: FTS search with ZodError 400 guard - POST /conversations/:id/bookmarks: toggle bookmark with UUID validation - GET /companies/:companyId/bookmarks: list bookmarks with optional conversationId filter - POST /conversations/:id/branch: branch conversation from message point - GET /conversations/:id/branches: list child conversations - GET /conversations/:id/export: download Markdown or JSON with Content-Disposition header --- server/src/routes/chat.ts | 68 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/server/src/routes/chat.ts b/server/src/routes/chat.ts index 12cdf114..f4d38517 100644 --- a/server/src/routes/chat.ts +++ b/server/src/routes/chat.ts @@ -3,11 +3,14 @@ import type { Db } from "@paperclipai/db"; import { assertBoard, assertCompanyAccess } from "./authz.js"; import { chatService } from "../services/chat.js"; import { issueService } from "../services/issues.js"; +import { z } from "zod"; import { createConversationSchema, updateConversationSchema, createMessageSchema, handoffSchema, + searchMessagesSchema, + branchConversationSchema, } from "@paperclipai/shared"; export function chatRoutes(db: Db): Router { @@ -207,5 +210,70 @@ export function chatRoutes(db: Db): Router { res.status(201).json(message); }); + // GET /api/companies/:companyId/messages/search + router.get("/companies/:companyId/messages/search", async (req, res) => { + assertBoard(req); + assertCompanyAccess(req, req.params.companyId!); + let parsed: { q: string; limit?: number }; + try { + parsed = searchMessagesSchema.parse({ q: req.query.q, limit: req.query.limit }); + } catch { + res.status(400).json({ error: "Query must be at least 2 characters" }); + return; + } + const result = await svc.searchMessages(req.params.companyId!, parsed.q, { limit: parsed.limit }); + res.json(result); + }); + + // POST /api/conversations/:id/bookmarks -- Toggle bookmark for a message + router.post("/conversations/:id/bookmarks", async (req, res) => { + assertBoard(req); + const messageId = z.string().uuid().safeParse(req.body.messageId); + if (!messageId.success) { + res.status(400).json({ error: "messageId must be a valid UUID" }); + return; + } + const conversationId = req.params.id!; + const conversation = await svc.getConversation(conversationId); + const result = await svc.toggleBookmark(conversation.companyId, messageId.data, conversationId); + res.json(result); + }); + + // GET /api/companies/:companyId/bookmarks + router.get("/companies/:companyId/bookmarks", async (req, res) => { + assertBoard(req); + assertCompanyAccess(req, req.params.companyId!); + const conversationId = req.query.conversationId as string | undefined; + const result = await svc.getBookmarks(req.params.companyId!, { conversationId }); + res.json(result); + }); + + // POST /api/conversations/:id/branch -- Branch conversation from a message + router.post("/conversations/:id/branch", async (req, res) => { + assertBoard(req); + const parsed = branchConversationSchema.parse(req.body); + const conv = await svc.getConversation(req.params.id!); + const newConversation = await svc.branchConversation(req.params.id!, parsed.branchFromMessageId, conv.companyId); + res.status(201).json(newConversation); + }); + + // GET /api/conversations/:id/branches -- List child conversations + router.get("/conversations/:id/branches", async (req, res) => { + assertBoard(req); + const branches = await svc.listBranches(req.params.id!); + res.json({ items: branches }); + }); + + // GET /api/conversations/:id/export -- Export conversation as Markdown or JSON file + router.get("/conversations/:id/export", async (req, res) => { + assertBoard(req); + const format = req.query.format === "json" ? "json" : "markdown"; + const { content, filename } = await svc.exportConversation(req.params.id!, format); + const mime = format === "json" ? "application/json" : "text/markdown"; + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Type", mime); + res.send(content); + }); + return router; }