From 54e1925b9e91f1a557ad2e8a9251ebe2f6203e4c Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 16:52:07 +0000 Subject: [PATCH] feat(21-03): add chatRoutes, mount in app.ts, export chat schemas from shared - chatRoutes factory with 7 REST endpoints for conversations and messages - All routes gated by assertBoard; company-scoped routes also use assertCompanyAccess - Mounted as api.use(chatRoutes(db)) after activityRoutes in app.ts - Export createConversationSchema/updateConversationSchema/createMessageSchema from @paperclipai/shared (were missing from main package index) - 11 vitest route tests passing --- packages/shared/src/index.ts | 9 ++ server/src/__tests__/chat-routes.test.ts | 188 +++++++++++++++++++++-- server/src/app.ts | 2 + server/src/routes/chat.ts | 79 ++++++++++ 4 files changed, 266 insertions(+), 12 deletions(-) create mode 100644 server/src/routes/chat.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f75dbf14..0663885d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -557,6 +557,15 @@ export { type ListPluginState, } from "./validators/index.js"; +export { + createConversationSchema, + updateConversationSchema, + createMessageSchema, + type CreateConversation, + type UpdateConversation, + type CreateMessage, +} from "./validators/index.js"; + export { API_PREFIX, API } from "./api.js"; export { normalizeAgentUrlKey, deriveAgentUrlKey, isUuidLike } from "./agent-url-key.js"; export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from "./project-url-key.js"; diff --git a/server/src/__tests__/chat-routes.test.ts b/server/src/__tests__/chat-routes.test.ts index 61064b99..6b161fdb 100644 --- a/server/src/__tests__/chat-routes.test.ts +++ b/server/src/__tests__/chat-routes.test.ts @@ -1,35 +1,199 @@ -import { describe, it } from "vitest"; +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { chatRoutes } from "../routes/chat.js"; + +const mockChatService = vi.hoisted(() => ({ + createConversation: vi.fn(), + listConversations: vi.fn(), + getConversation: vi.fn(), + updateConversation: vi.fn(), + softDeleteConversation: vi.fn(), + listMessages: vi.fn(), + addMessage: vi.fn(), +})); + +vi.mock("../services/chat.js", () => ({ + chatService: () => mockChatService, +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", chatRoutes({} as any)); + app.use(errorHandler); + return app; +} describe("chatRoutes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe("POST /companies/:companyId/conversations", () => { - it.todo("creates a conversation and returns 201"); - it.todo("accepts optional title and agentId"); + it("creates a conversation and returns 201", async () => { + const conv = { id: "conv-1", companyId: "company-1", title: null }; + mockChatService.createConversation.mockResolvedValue(conv); + + const res = await request(createApp()) + .post("/api/companies/company-1/conversations") + .send({}); + + expect(res.status).toBe(201); + expect(res.body).toEqual(conv); + expect(mockChatService.createConversation).toHaveBeenCalledWith("company-1", {}); + }); + + it("accepts optional title and agentId", async () => { + const conv = { id: "conv-1", companyId: "company-1", title: "My Chat" }; + mockChatService.createConversation.mockResolvedValue(conv); + + const res = await request(createApp()) + .post("/api/companies/company-1/conversations") + .send({ title: "My Chat", agentId: "00000000-0000-0000-0000-000000000001" }); + + expect(res.status).toBe(201); + expect(mockChatService.createConversation).toHaveBeenCalledWith( + "company-1", + { title: "My Chat", agentId: "00000000-0000-0000-0000-000000000001" }, + ); + }); }); describe("GET /companies/:companyId/conversations", () => { - it.todo("returns paginated conversation list"); - it.todo("supports cursor query param"); + it("returns paginated conversation list", async () => { + const list = { items: [{ id: "conv-1" }], hasMore: false, nextCursor: null }; + mockChatService.listConversations.mockResolvedValue(list); + + const res = await request(createApp()) + .get("/api/companies/company-1/conversations"); + + expect(res.status).toBe(200); + expect(res.body).toEqual(list); + expect(mockChatService.listConversations).toHaveBeenCalledWith( + "company-1", + { cursor: undefined, limit: undefined, includeArchived: false }, + ); + }); + + it("supports cursor query param", async () => { + const list = { items: [], hasMore: false, nextCursor: null }; + mockChatService.listConversations.mockResolvedValue(list); + + const res = await request(createApp()) + .get("/api/companies/company-1/conversations?cursor=2024-01-01T00:00:00.000Z&limit=10"); + + expect(res.status).toBe(200); + expect(mockChatService.listConversations).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ cursor: "2024-01-01T00:00:00.000Z", limit: 10 }), + ); + }); }); describe("GET /conversations/:id", () => { - it.todo("returns conversation by id"); - it.todo("returns 404 for non-existent conversation"); + it("returns conversation by id", async () => { + const conv = { id: "conv-1", companyId: "company-1" }; + mockChatService.getConversation.mockResolvedValue(conv); + + const res = await request(createApp()).get("/api/conversations/conv-1"); + + expect(res.status).toBe(200); + expect(res.body).toEqual(conv); + expect(mockChatService.getConversation).toHaveBeenCalledWith("conv-1"); + }); + + it("returns 404 for non-existent conversation", async () => { + const { HttpError } = await import("../errors.js"); + mockChatService.getConversation.mockRejectedValue( + new HttpError(404, "Conversation not found"), + ); + + const res = await request(createApp()).get("/api/conversations/no-such-id"); + + expect(res.status).toBe(404); + }); }); describe("PATCH /conversations/:id", () => { - it.todo("updates conversation fields"); + it("updates conversation fields", async () => { + const updated = { id: "conv-1", title: "Updated" }; + mockChatService.updateConversation.mockResolvedValue(updated); + + const res = await request(createApp()) + .patch("/api/conversations/conv-1") + .send({ title: "Updated" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual(updated); + expect(mockChatService.updateConversation).toHaveBeenCalledWith( + "conv-1", + { title: "Updated" }, + ); + }); }); describe("DELETE /conversations/:id", () => { - it.todo("soft-deletes and returns 204"); + it("soft-deletes and returns 204", async () => { + mockChatService.softDeleteConversation.mockResolvedValue({ id: "conv-1" }); + + const res = await request(createApp()).delete("/api/conversations/conv-1"); + + expect(res.status).toBe(204); + expect(mockChatService.softDeleteConversation).toHaveBeenCalledWith("conv-1"); + }); }); describe("POST /conversations/:id/messages", () => { - it.todo("creates a message and returns 201"); - it.todo("rejects invalid role"); + it("creates a message and returns 201", async () => { + const message = { id: "msg-1", conversationId: "conv-1", role: "user", content: "Hello" }; + mockChatService.addMessage.mockResolvedValue(message); + + const res = await request(createApp()) + .post("/api/conversations/conv-1/messages") + .send({ role: "user", content: "Hello" }); + + expect(res.status).toBe(201); + expect(res.body).toEqual(message); + expect(mockChatService.addMessage).toHaveBeenCalledWith( + "conv-1", + { role: "user", content: "Hello" }, + ); + }); + + it("rejects invalid role", async () => { + const res = await request(createApp()) + .post("/api/conversations/conv-1/messages") + .send({ role: "invalid-role", content: "Hello" }); + + expect(res.status).toBe(400); + }); }); describe("GET /conversations/:id/messages", () => { - it.todo("returns paginated message list"); + it("returns paginated message list", async () => { + const list = { items: [{ id: "msg-1" }], hasMore: false, nextCursor: null }; + mockChatService.listMessages.mockResolvedValue(list); + + const res = await request(createApp()).get("/api/conversations/conv-1/messages"); + + expect(res.status).toBe(200); + expect(res.body).toEqual(list); + expect(mockChatService.listMessages).toHaveBeenCalledWith( + "conv-1", + { cursor: undefined, limit: undefined }, + ); + }); }); }); diff --git a/server/src/app.ts b/server/src/app.ts index 5d314e72..28dff0b4 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -24,6 +24,7 @@ import { approvalRoutes } from "./routes/approvals.js"; import { secretRoutes } from "./routes/secrets.js"; import { costRoutes } from "./routes/costs.js"; import { activityRoutes } from "./routes/activity.js"; +import { chatRoutes } from "./routes/chat.js"; import { dashboardRoutes } from "./routes/dashboard.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; @@ -156,6 +157,7 @@ export async function createApp( api.use(secretRoutes(db)); api.use(costRoutes(db)); api.use(activityRoutes(db)); + api.use(chatRoutes(db)); api.use(dashboardRoutes(db)); api.use(sidebarBadgeRoutes(db)); api.use(instanceSettingsRoutes(db)); diff --git a/server/src/routes/chat.ts b/server/src/routes/chat.ts new file mode 100644 index 00000000..978400f6 --- /dev/null +++ b/server/src/routes/chat.ts @@ -0,0 +1,79 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { assertBoard, assertCompanyAccess } from "./authz.js"; +import { chatService } from "../services/chat.js"; +import { + createConversationSchema, + updateConversationSchema, + createMessageSchema, +} from "@paperclipai/shared"; + +export function chatRoutes(db: Db): Router { + const router = Router(); + const svc = chatService(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 } = req.query; + const result = await svc.listConversations(req.params.companyId!, { + cursor: cursor as string | undefined, + limit: limit ? Number(limit) : undefined, + includeArchived: includeArchived === "true", + }); + 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); + }); + + return router; +}