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
This commit is contained in:
Nexus Dev 2026-04-01 22:31:59 +00:00
parent 61161a3561
commit 505f5e2262

View file

@ -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;
}