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
This commit is contained in:
Nexus Dev 2026-04-01 16:52:07 +00:00
parent c3c4145f9b
commit 54e1925b9e
4 changed files with 266 additions and 12 deletions

View file

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

View file

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

View file

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

79
server/src/routes/chat.ts Normal file
View file

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