nexus/server/src/routes/chat.ts
Nexus Dev 505f5e2262 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
2026-04-04 03:55:48 +00:00

279 lines
10 KiB
TypeScript

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