diff --git a/server/src/__tests__/chat-routes.test.ts b/server/src/__tests__/chat-routes.test.ts new file mode 100644 index 00000000..88779a07 --- /dev/null +++ b/server/src/__tests__/chat-routes.test.ts @@ -0,0 +1,219 @@ +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(() => ({ + listConversations: vi.fn(), + createConversation: vi.fn(), + getConversation: vi.fn(), + updateConversation: vi.fn(), + softDeleteConversation: vi.fn(), + archiveConversation: vi.fn(), + unarchiveConversation: vi.fn(), + pinConversation: vi.fn(), + unpinConversation: 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("chat routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("GET /api/companies/:companyId/conversations", () => { + it("returns 200 with { items: [], hasMore: false } when empty", async () => { + mockChatService.listConversations.mockResolvedValue({ items: [], hasMore: false }); + + const res = await request(createApp()).get("/api/companies/company-1/conversations"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ items: [], hasMore: false }); + expect(mockChatService.listConversations).toHaveBeenCalledWith("company-1", expect.any(Object)); + }); + }); + + describe("POST /api/companies/:companyId/conversations", () => { + it("returns 201 with the created conversation object", async () => { + const created = { + id: "conv-1", + companyId: "company-1", + title: "Test", + agentId: null, + pinnedAt: null, + archivedAt: null, + deletedAt: null, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + mockChatService.createConversation.mockResolvedValue(created); + + const res = await request(createApp()) + .post("/api/companies/company-1/conversations") + .send({ title: "Test" }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ id: "conv-1", title: "Test" }); + }); + }); + + describe("GET /api/conversations/:id", () => { + it("returns 200 with conversation object", async () => { + const conv = { + id: "conv-1", + companyId: "company-1", + title: "Test", + agentId: null, + pinnedAt: null, + archivedAt: null, + deletedAt: null, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + mockChatService.getConversation.mockResolvedValue(conv); + + const res = await request(createApp()).get("/api/conversations/conv-1"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ id: "conv-1" }); + }); + + it("returns 404 when conversation not found", async () => { + mockChatService.getConversation.mockResolvedValue(null); + + const res = await request(createApp()).get("/api/conversations/nonexistent"); + + expect(res.status).toBe(404); + }); + }); + + describe("PATCH /api/conversations/:id", () => { + it("returns 200 with updated conversation", async () => { + const updated = { + id: "conv-1", + companyId: "company-1", + title: "new title", + agentId: null, + pinnedAt: null, + archivedAt: null, + deletedAt: null, + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + mockChatService.updateConversation.mockResolvedValue(updated); + + const res = await request(createApp()) + .patch("/api/conversations/conv-1") + .send({ title: "new title" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ title: "new title" }); + }); + }); + + describe("DELETE /api/conversations/:id", () => { + it("returns 204", async () => { + mockChatService.softDeleteConversation.mockResolvedValue(undefined); + + const res = await request(createApp()).delete("/api/conversations/conv-1"); + + expect(res.status).toBe(204); + }); + }); + + describe("POST /api/conversations/:id/archive", () => { + it("returns 200", async () => { + mockChatService.archiveConversation.mockResolvedValue({ id: "conv-1", archivedAt: "2024-01-01" }); + + const res = await request(createApp()).post("/api/conversations/conv-1/archive"); + + expect(res.status).toBe(200); + }); + }); + + describe("POST /api/conversations/:id/unarchive", () => { + it("returns 200", async () => { + mockChatService.unarchiveConversation.mockResolvedValue({ id: "conv-1", archivedAt: null }); + + const res = await request(createApp()).post("/api/conversations/conv-1/unarchive"); + + expect(res.status).toBe(200); + }); + }); + + describe("POST /api/conversations/:id/pin", () => { + it("returns 200", async () => { + mockChatService.pinConversation.mockResolvedValue({ id: "conv-1", pinnedAt: "2024-01-01" }); + + const res = await request(createApp()).post("/api/conversations/conv-1/pin"); + + expect(res.status).toBe(200); + }); + }); + + describe("POST /api/conversations/:id/unpin", () => { + it("returns 200", async () => { + mockChatService.unpinConversation.mockResolvedValue({ id: "conv-1", pinnedAt: null }); + + const res = await request(createApp()).post("/api/conversations/conv-1/unpin"); + + expect(res.status).toBe(200); + }); + }); + + describe("GET /api/conversations/:id/messages", () => { + it("returns 200 with { items: [], hasMore: false }", async () => { + mockChatService.listMessages.mockResolvedValue({ items: [], hasMore: false }); + + const res = await request(createApp()).get("/api/conversations/conv-1/messages"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ items: [], hasMore: false }); + }); + }); + + describe("POST /api/conversations/:id/messages", () => { + it("returns 201 with created message", async () => { + const message = { + id: "msg-1", + conversationId: "conv-1", + role: "user", + content: "hello", + agentId: null, + createdAt: "2024-01-01T00:00:00.000Z", + }; + 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).toMatchObject({ id: "msg-1", content: "hello" }); + }); + }); +}); 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..c1bfa7e3 --- /dev/null +++ b/server/src/routes/chat.ts @@ -0,0 +1,101 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { assertBoard, assertCompanyAccess } from "./authz.js"; +import { chatService } from "../services/chat.js"; +import { validate } from "../middleware/validate.js"; +import { createConversationSchema, updateConversationSchema, createMessageSchema } from "@paperclipai/shared"; + +export function chatRoutes(db: Db) { + const router = Router(); + const svc = chatService(db); + + // GET /api/companies/:companyId/conversations + router.get("/companies/:companyId/conversations", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const cursor = req.query.cursor as string | undefined; + const limit = req.query.limit ? Number(req.query.limit) : undefined; + const result = await svc.listConversations(companyId, { cursor, limit }); + res.json(result); + }); + + // POST /api/companies/:companyId/conversations + router.post("/companies/:companyId/conversations", validate(createConversationSchema), async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const conversation = await svc.createConversation(companyId, req.body); + 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 as string); + if (!conversation) { + res.status(404).json({ error: "Not found" }); + return; + } + res.json(conversation); + }); + + // PATCH /api/conversations/:id + router.patch("/conversations/:id", validate(updateConversationSchema), async (req, res) => { + assertBoard(req); + const conversation = await svc.updateConversation(req.params.id as string, req.body); + res.json(conversation); + }); + + // DELETE /api/conversations/:id + router.delete("/conversations/:id", async (req, res) => { + assertBoard(req); + await svc.softDeleteConversation(req.params.id as string); + res.status(204).end(); + }); + + // POST /api/conversations/:id/archive + router.post("/conversations/:id/archive", async (req, res) => { + assertBoard(req); + const result = await svc.archiveConversation(req.params.id as string); + res.json(result); + }); + + // POST /api/conversations/:id/unarchive + router.post("/conversations/:id/unarchive", async (req, res) => { + assertBoard(req); + const result = await svc.unarchiveConversation(req.params.id as string); + res.json(result); + }); + + // POST /api/conversations/:id/pin + router.post("/conversations/:id/pin", async (req, res) => { + assertBoard(req); + const result = await svc.pinConversation(req.params.id as string); + res.json(result); + }); + + // POST /api/conversations/:id/unpin + router.post("/conversations/:id/unpin", async (req, res) => { + assertBoard(req); + const result = await svc.unpinConversation(req.params.id as string); + res.json(result); + }); + + // GET /api/conversations/:id/messages + router.get("/conversations/:id/messages", async (req, res) => { + assertBoard(req); + const cursor = req.query.cursor as string | undefined; + const limit = req.query.limit ? Number(req.query.limit) : undefined; + const result = await svc.listMessages(req.params.id as string, { cursor, limit }); + res.json(result); + }); + + // POST /api/conversations/:id/messages + router.post("/conversations/:id/messages", validate(createMessageSchema), async (req, res) => { + assertBoard(req); + const message = await svc.addMessage(req.params.id as string, req.body); + res.status(201).json(message); + }); + + return router; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index dd9c0b54..3e6ac707 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -15,3 +15,4 @@ export { sidebarBadgeRoutes } from "./sidebar-badges.js"; export { llmRoutes } from "./llms.js"; export { accessRoutes } from "./access.js"; export { instanceSettingsRoutes } from "./instance-settings.js"; +export { chatRoutes } from "./chat.js";