import { Router } from "express"; 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 { const router = Router(); const svc = chatService(db); const issueSvc = issueService(db); // GET /api/companies/:companyId/conversations router.get("/companies/:companyId/conversations", async (req, res) => { assertBoard(req); assertCompanyAccess(req, req.params.companyId!); const { cursor, limit, includeArchived, search, agentId } = req.query; const result = await svc.listConversations(req.params.companyId!, { cursor: cursor as string | undefined, limit: limit ? Number(limit) : undefined, includeArchived: includeArchived === "true", search: search as string | undefined, agentId: agentId as string | undefined, }); res.json(result); }); // POST /api/companies/:companyId/conversations router.post("/companies/:companyId/conversations", async (req, res) => { assertBoard(req); assertCompanyAccess(req, req.params.companyId!); const data = createConversationSchema.parse(req.body); const conversation = await svc.createConversation(req.params.companyId!, data); res.status(201).json(conversation); }); // GET /api/conversations/:id router.get("/conversations/:id", async (req, res) => { assertBoard(req); const conversation = await svc.getConversation(req.params.id!); res.json(conversation); }); // PATCH /api/conversations/:id router.patch("/conversations/:id", async (req, res) => { assertBoard(req); const data = updateConversationSchema.parse(req.body); const conversation = await svc.updateConversation(req.params.id!, data); res.json(conversation); }); // DELETE /api/conversations/:id router.delete("/conversations/:id", async (req, res) => { assertBoard(req); await svc.softDeleteConversation(req.params.id!); res.status(204).end(); }); // GET /api/conversations/:id/messages router.get("/conversations/:id/messages", async (req, res) => { assertBoard(req); const { cursor, limit } = req.query; const result = await svc.listMessages(req.params.id!, { cursor: cursor as string | undefined, limit: limit ? Number(limit) : undefined, }); res.json(result); }); // POST /api/conversations/:id/messages router.post("/conversations/:id/messages", async (req, res) => { assertBoard(req); const data = createMessageSchema.parse(req.body); const message = await svc.addMessage(req.params.id!, data); res.status(201).json(message); }); // POST /api/conversations/:id/stream -- SSE streaming endpoint (CHAT-01, PERF-02) router.post("/conversations/:id/stream", async (req, res) => { assertBoard(req); const { content, agentId } = req.body; if (!content || typeof content !== "string") { res.status(400).json({ error: "content is required" }); return; } // Set SSE headers and flush BEFORE any generation (PERF-02) res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); res.flushHeaders(); res.write(":ok\n\n"); const abort = new AbortController(); req.on("close", () => abort.abort()); try { let fullContent = ""; for await (const token of svc.streamEcho(content, abort.signal)) { if (!res.writable) break; fullContent += token; res.write(`data: ${JSON.stringify({ token })}\n\n`); } if (res.writable && !abort.signal.aborted) { const message = await svc.addMessage(req.params.id!, { role: "assistant", content: fullContent.trim(), agentId: agentId || undefined, }); res.write(`data: ${JSON.stringify({ done: true, messageId: message.id, content: fullContent.trim() })}\n\n`); } } catch (err) { if (res.writable && !abort.signal.aborted) { res.write(`data: ${JSON.stringify({ error: "Stream error" })}\n\n`); } } finally { res.end(); } }); // PATCH /api/conversations/:id/messages/:msgId -- Edit message content router.patch("/conversations/:id/messages/:msgId", async (req, res) => { assertBoard(req); const { content } = req.body; if (!content || typeof content !== "string") { res.status(400).json({ error: "content is required" }); return; } const message = await svc.editMessage(req.params.msgId!, content); if (!message) { res.status(404).json({ error: "Message not found" }); return; } res.json(message); }); // DELETE /api/conversations/:id/messages/after/:msgId -- Truncate messages after a given message router.delete("/conversations/:id/messages/after/:msgId", async (req, res) => { assertBoard(req); await svc.truncateMessagesAfter(req.params.id!, req.params.msgId!); res.status(204).end(); }); // POST /api/conversations/:id/handoff -- Brainstormer handoff to PM: inserts handoff + task_created messages and creates an issue router.post("/conversations/:id/handoff", async (req, res) => { assertBoard(req); const data = handoffSchema.parse(req.body); // Resolve companyId from conversation const conversation = await svc.getConversation(req.params.id!); const companyId = conversation.companyId; // 1. Insert handoff system message const handoffMsg = await svc.addSystemMessage(req.params.id!, { content: `Brainstormer \u2192 PM: spec handed off`, messageType: "handoff", }); // 2. Create issue from spec const specDescription = [ `**What:** ${data.spec.what}`, `**Why:** ${data.spec.why}`, data.spec.constraints ? `**Constraints:** ${data.spec.constraints}` : "", data.spec.success ? `**Success:** ${data.spec.success}` : "", ].filter(Boolean).join("\n\n"); const issue = await issueSvc.create(companyId, { title: data.spec.what.slice(0, 100), description: specDescription, status: "backlog", priority: "medium", }); // 3. Insert task_created system message await svc.addSystemMessage(req.params.id!, { content: JSON.stringify({ taskId: issue.identifier, taskTitle: issue.title, taskUrl: `/issues/${issue.id}`, }), messageType: "task_created", }); res.json({ handoffMessageId: handoffMsg.id, issues: [issue] }); }); // POST /api/conversations/:id/status-update -- Agent completion notification in chat router.post("/conversations/:id/status-update", async (req, res) => { assertBoard(req); const { agentName, taskId, taskTitle, taskUrl } = req.body; if (!agentName || !taskId) { res.status(400).json({ error: "agentName and taskId are required" }); return; } const message = await svc.addSystemMessage(req.params.id!, { content: JSON.stringify({ agentName, taskId, taskTitle, taskUrl }), messageType: "status_update", }); 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; }