From fb423b4d66958a4d624696b296a67db01b84987f Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 16:48:43 +0000 Subject: [PATCH] feat(21-03): implement chatService with full conversation + message CRUD - createConversation, listConversations, getConversation, updateConversation - softDeleteConversation, listMessages, addMessage - cursor-based pagination with hasMore for both conversations and messages - Pitfall 3: addMessage bumps conversation updatedAt after insert - Pitfall 5: addMessage auto-sets title from first user message (IS NULL guard) - 21 vitest tests passing --- server/src/__tests__/chat-service.test.ts | 386 ++++++++++++++++++++-- server/src/services/chat.ts | 161 +++++++++ 2 files changed, 524 insertions(+), 23 deletions(-) create mode 100644 server/src/services/chat.ts diff --git a/server/src/__tests__/chat-service.test.ts b/server/src/__tests__/chat-service.test.ts index 53802d5a..0bd46e99 100644 --- a/server/src/__tests__/chat-service.test.ts +++ b/server/src/__tests__/chat-service.test.ts @@ -1,46 +1,386 @@ -import { describe, it } from "vitest"; +import { and, desc, eq, isNull, lt } from "drizzle-orm"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { chatConversations, chatMessages } from "@paperclipai/db"; +import { chatService } from "../services/chat.js"; + +/** + * Build a query chain mock. + * - `selectResult`: what `db.select()...limit()` resolves to (array) + * - `returningResult`: what `db.insert/update()...returning()` resolves to (array) + */ +function makeSelectChain(rows: unknown[], opts: { terminalAtWhere?: boolean } = {}) { + const chain: any = {}; + const methods = ["select", "from", "orderBy"]; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + chain.limit = vi.fn(() => Promise.resolve(rows)); + if (opts.terminalAtWhere) { + // getConversation: select().from().where() — no orderBy/limit + chain.where = vi.fn(() => Promise.resolve(rows)); + } else { + // listConversations / listMessages: select().from().where().orderBy().limit() + chain.where = vi.fn(() => chain); + } + return chain; +} + +function makeUpdateChain(rows: unknown[]) { + const chain: any = {}; + chain.set = vi.fn(() => chain); + chain.where = vi.fn(() => chain); + chain.returning = vi.fn(() => Promise.resolve(rows)); + return chain; +} + +function makeInsertChain(rows: unknown[]) { + const chain: any = {}; + chain.values = vi.fn(() => chain); + chain.returning = vi.fn(() => Promise.resolve(rows)); + return chain; +} + +function makeDb() { + return { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; +} describe("chatService", () => { + let db: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + db = makeDb(); + }); + describe("createConversation", () => { - it.todo("creates a conversation row with companyId"); - it.todo("returns the created conversation with id and timestamps"); + it("inserts into chatConversations and returns the created row", async () => { + const inserted = { + id: "conv-1", + companyId: "company-1", + title: "Hello", + agentId: null, + pinnedAt: null, + archivedAt: null, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const chain = makeInsertChain([inserted]); + db.insert.mockReturnValue(chain); + + const svc = chatService(db as any); + const result = await svc.createConversation("company-1", { title: "Hello" }); + + expect(db.insert).toHaveBeenCalledWith(chatConversations); + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ companyId: "company-1", title: "Hello" }), + ); + expect(result).toEqual(inserted); + }); + + it("returns the created conversation with id and timestamps", async () => { + const now = new Date(); + const inserted = { + id: "conv-1", + companyId: "company-1", + title: null, + agentId: null, + pinnedAt: null, + archivedAt: null, + deletedAt: null, + createdAt: now, + updatedAt: now, + }; + const chain = makeInsertChain([inserted]); + db.insert.mockReturnValue(chain); + + const svc = chatService(db as any); + const result = await svc.createConversation("company-1", {}); + + expect(result.id).toBe("conv-1"); + expect(result.createdAt).toBe(now); + }); }); describe("listConversations", () => { - it.todo("returns conversations sorted by updatedAt DESC"); - it.todo("excludes soft-deleted conversations"); - it.todo("supports cursor-based pagination with hasMore"); - it.todo("limits results to max 100"); + it("returns conversations sorted by updatedAt DESC", async () => { + const rows = [ + { id: "conv-2", updatedAt: new Date("2024-01-02") }, + { id: "conv-1", updatedAt: new Date("2024-01-01") }, + ]; + + const chain = makeSelectChain(rows); + db.select.mockReturnValue(chain); + + const svc = chatService(db as any); + await svc.listConversations("company-1", {}); + + expect(db.select).toHaveBeenCalled(); + expect(chain.orderBy).toHaveBeenCalledWith(desc(chatConversations.updatedAt)); + }); + + it("excludes soft-deleted conversations", async () => { + const chain = makeSelectChain([]); + db.select.mockReturnValue(chain); + + const svc = chatService(db as any); + await svc.listConversations("company-1", {}); + + // where should be called with a combined condition that includes isNull(deletedAt) + expect(chain.where).toHaveBeenCalled(); + }); + + it("supports cursor-based pagination with hasMore", async () => { + // Return limit+1 rows to trigger hasMore=true + const rows = Array.from({ length: 31 }, (_, i) => ({ + id: `conv-${i}`, + updatedAt: new Date(2024, 0, 31 - i), + })); + + const chain = makeSelectChain(rows); + db.select.mockReturnValue(chain); + + const svc = chatService(db as any); + const result = await svc.listConversations("company-1", { limit: 30 }); + + expect(result.hasMore).toBe(true); + expect(result.items.length).toBe(30); + }); + + it("limits results to max 100", async () => { + const chain = makeSelectChain([]); + db.select.mockReturnValue(chain); + + const svc = chatService(db as any); + await svc.listConversations("company-1", { limit: 9999 }); + + // limit should be called with 101 (100 + 1 for hasMore detection) + expect(chain.limit).toHaveBeenCalledWith(101); + }); }); describe("getConversation", () => { - it.todo("returns conversation by id"); - it.todo("throws notFound for non-existent conversation"); - it.todo("throws notFound for soft-deleted conversation"); + it("returns conversation by id", async () => { + const conv = { id: "conv-1", companyId: "company-1", deletedAt: null }; + const chain = makeSelectChain([conv], { terminalAtWhere: true }); + db.select.mockReturnValue(chain); + + const svc = chatService(db as any); + const result = await svc.getConversation("conv-1"); + + expect(result).toEqual(conv); + }); + + it("throws notFound for non-existent conversation", async () => { + const chain = makeSelectChain([], { terminalAtWhere: true }); + db.select.mockReturnValue(chain); + + const svc = chatService(db as any); + await expect(svc.getConversation("no-such-id")).rejects.toMatchObject({ + status: 404, + }); + }); + + it("throws notFound for soft-deleted conversation", async () => { + // When deletedAt is set, the WHERE clause won't match — returns empty + const chain = makeSelectChain([], { terminalAtWhere: true }); + db.select.mockReturnValue(chain); + + const svc = chatService(db as any); + await expect(svc.getConversation("deleted-id")).rejects.toMatchObject({ + status: 404, + }); + }); }); describe("updateConversation", () => { - it.todo("updates title"); - it.todo("sets pinnedAt timestamp"); - it.todo("clears pinnedAt when set to null"); - it.todo("sets archivedAt timestamp"); - it.todo("bumps updatedAt on every update"); + it("updates title and bumps updatedAt", async () => { + const updated = { id: "conv-1", title: "New Title", updatedAt: new Date() }; + const chain = makeUpdateChain([updated]); + db.update.mockReturnValue(chain); + + const svc = chatService(db as any); + const result = await svc.updateConversation("conv-1", { title: "New Title" }); + + expect(db.update).toHaveBeenCalledWith(chatConversations); + expect(chain.set).toHaveBeenCalledWith( + expect.objectContaining({ title: "New Title", updatedAt: expect.any(Date) }), + ); + expect(result).toEqual(updated); + }); + + it("sets pinnedAt timestamp when provided as string", async () => { + const pinnedAt = "2024-06-01T00:00:00.000Z"; + const updated = { id: "conv-1", pinnedAt: new Date(pinnedAt) }; + const chain = makeUpdateChain([updated]); + db.update.mockReturnValue(chain); + + const svc = chatService(db as any); + await svc.updateConversation("conv-1", { pinnedAt }); + + expect(chain.set).toHaveBeenCalledWith( + expect.objectContaining({ pinnedAt: new Date(pinnedAt) }), + ); + }); + + it("clears pinnedAt when set to null", async () => { + const updated = { id: "conv-1", pinnedAt: null }; + const chain = makeUpdateChain([updated]); + db.update.mockReturnValue(chain); + + const svc = chatService(db as any); + await svc.updateConversation("conv-1", { pinnedAt: null }); + + expect(chain.set).toHaveBeenCalledWith( + expect.objectContaining({ pinnedAt: null }), + ); + }); + + it("bumps updatedAt on every update", async () => { + const before = new Date("2024-01-01"); + const updated = { id: "conv-1", updatedAt: new Date() }; + const chain = makeUpdateChain([updated]); + db.update.mockReturnValue(chain); + + const svc = chatService(db as any); + await svc.updateConversation("conv-1", { title: "x" }); + + const setCall = chain.set.mock.calls[0][0]; + expect(setCall.updatedAt).toBeInstanceOf(Date); + expect(setCall.updatedAt.getTime()).toBeGreaterThan(before.getTime()); + }); }); describe("softDeleteConversation", () => { - it.todo("sets deletedAt timestamp"); - it.todo("throws notFound if already deleted"); + it("sets deletedAt and returns the updated row", async () => { + const deleted = { id: "conv-1", deletedAt: new Date() }; + const chain = makeUpdateChain([deleted]); + db.update.mockReturnValue(chain); + + const svc = chatService(db as any); + const result = await svc.softDeleteConversation("conv-1"); + + expect(db.update).toHaveBeenCalledWith(chatConversations); + expect(chain.set).toHaveBeenCalledWith( + expect.objectContaining({ deletedAt: expect.any(Date) }), + ); + expect(result).toEqual(deleted); + }); + + it("throws notFound if conversation is already deleted (no row returned)", async () => { + const chain = makeUpdateChain([]); + db.update.mockReturnValue(chain); + + const svc = chatService(db as any); + await expect(svc.softDeleteConversation("gone-id")).rejects.toMatchObject({ + status: 404, + }); + }); }); describe("addMessage", () => { - it.todo("inserts a message row with conversationId and role"); - it.todo("bumps conversation updatedAt after insert"); - it.todo("auto-sets title from first user message when title is null"); - it.todo("does not overwrite existing title on subsequent messages"); + it("inserts a message row with conversationId and role", async () => { + const message = { id: "msg-1", conversationId: "conv-1", role: "user", content: "Hi" }; + const insertChain = makeInsertChain([message]); + const updateChain = makeUpdateChain([]); + + db.insert.mockReturnValue(insertChain); + db.update.mockReturnValue(updateChain); + + const svc = chatService(db as any); + const result = await svc.addMessage("conv-1", { role: "user", content: "Hi" }); + + expect(db.insert).toHaveBeenCalledWith(chatMessages); + expect(insertChain.values).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "conv-1", role: "user", content: "Hi" }), + ); + expect(result).toEqual(message); + }); + + it("bumps conversation updatedAt after insert (Pitfall 3)", async () => { + const message = { id: "msg-1", conversationId: "conv-1", role: "user", content: "Hi" }; + const insertChain = makeInsertChain([message]); + const updateChain = makeUpdateChain([]); + + db.insert.mockReturnValue(insertChain); + db.update.mockReturnValue(updateChain); + + const svc = chatService(db as any); + await svc.addMessage("conv-1", { role: "user", content: "Hi" }); + + expect(db.update).toHaveBeenCalledWith(chatConversations); + }); + + it("auto-sets title from first user message when title is null (Pitfall 5)", async () => { + const content = "This is a long message that should be truncated to 60 chars when used as title"; + const message = { id: "msg-1", conversationId: "conv-1", role: "user", content }; + const insertChain = makeInsertChain([message]); + const updateChain = makeUpdateChain([]); + + db.insert.mockReturnValue(insertChain); + db.update.mockReturnValue(updateChain); + + const svc = chatService(db as any); + await svc.addMessage("conv-1", { role: "user", content }); + + // Should be called twice: once for updatedAt bump, once for title auto-set + expect(db.update).toHaveBeenCalledTimes(2); + }); + + it("does not auto-set title for assistant/system messages", async () => { + const content = "Response from assistant"; + const message = { id: "msg-1", conversationId: "conv-1", role: "assistant", content }; + const insertChain = makeInsertChain([message]); + const updateChain = makeUpdateChain([]); + + db.insert.mockReturnValue(insertChain); + db.update.mockReturnValue(updateChain); + + const svc = chatService(db as any); + await svc.addMessage("conv-1", { role: "assistant", content }); + + // Only one update: updatedAt bump (no auto-title for non-user messages) + expect(db.update).toHaveBeenCalledTimes(1); + }); }); describe("listMessages", () => { - it.todo("returns messages for conversation sorted by createdAt DESC"); - it.todo("supports cursor-based pagination"); + it("returns messages for conversation sorted by createdAt DESC", async () => { + const rows = [ + { id: "msg-2", createdAt: new Date("2024-01-02") }, + { id: "msg-1", createdAt: new Date("2024-01-01") }, + ]; + + const chain = makeSelectChain(rows); + db.select.mockReturnValue(chain); + + const svc = chatService(db as any); + await svc.listMessages("conv-1", {}); + + expect(db.select).toHaveBeenCalled(); + expect(chain.orderBy).toHaveBeenCalledWith(desc(chatMessages.createdAt)); + }); + + it("supports cursor-based pagination", async () => { + const rows = Array.from({ length: 51 }, (_, i) => ({ + id: `msg-${i}`, + createdAt: new Date(2024, 0, 51 - i), + })); + + const chain = makeSelectChain(rows); + db.select.mockReturnValue(chain); + + const svc = chatService(db as any); + const result = await svc.listMessages("conv-1", { limit: 50 }); + + expect(result.hasMore).toBe(true); + expect(result.items.length).toBe(50); + }); }); }); diff --git a/server/src/services/chat.ts b/server/src/services/chat.ts new file mode 100644 index 00000000..6bc8296a --- /dev/null +++ b/server/src/services/chat.ts @@ -0,0 +1,161 @@ +import { and, desc, eq, isNull, lt } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { chatConversations, chatMessages } from "@paperclipai/db"; +import { notFound } from "../errors.js"; + +export function chatService(db: Db) { + return { + async listConversations( + companyId: string, + opts: { cursor?: string; limit?: number; includeArchived?: boolean }, + ) { + const limit = Math.min(opts.limit ?? 30, 100); + const includeArchived = opts.includeArchived ?? false; + + const conditions = [ + eq(chatConversations.companyId, companyId), + isNull(chatConversations.deletedAt), + ]; + + if (!includeArchived) { + conditions.push(isNull(chatConversations.archivedAt)); + } + + if (opts.cursor) { + conditions.push(lt(chatConversations.updatedAt, new Date(opts.cursor))); + } + + const rows = await db + .select() + .from(chatConversations) + .where(and(...conditions)) + .orderBy(desc(chatConversations.updatedAt)) + .limit(limit + 1); + + const hasMore = rows.length > limit; + const items = hasMore ? rows.slice(0, limit) : rows; + const nextCursor = hasMore ? items[items.length - 1]!.updatedAt.toISOString() : null; + + return { items, hasMore, nextCursor }; + }, + + async createConversation(companyId: string, data: { title?: string; agentId?: string }) { + const [row] = await db + .insert(chatConversations) + .values({ + companyId, + title: data.title ?? null, + agentId: data.agentId ?? null, + }) + .returning(); + return row!; + }, + + async getConversation(id: string) { + const [row] = await db + .select() + .from(chatConversations) + .where(and(eq(chatConversations.id, id), isNull(chatConversations.deletedAt))); + if (!row) throw notFound("Conversation not found"); + return row; + }, + + async updateConversation( + id: string, + data: { + title?: string; + agentId?: string | null; + pinnedAt?: string | null; + archivedAt?: string | null; + }, + ) { + const patch: Record = { updatedAt: new Date() }; + + if (data.title !== undefined) patch.title = data.title; + if (data.agentId !== undefined) patch.agentId = data.agentId; + if ("pinnedAt" in data) { + patch.pinnedAt = data.pinnedAt ? new Date(data.pinnedAt) : null; + } + if ("archivedAt" in data) { + patch.archivedAt = data.archivedAt ? new Date(data.archivedAt) : null; + } + + const [row] = await db + .update(chatConversations) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .set(patch as any) + .where(eq(chatConversations.id, id)) + .returning(); + + return row!; + }, + + async softDeleteConversation(id: string) { + const now = new Date(); + const [row] = await db + .update(chatConversations) + .set({ deletedAt: now, updatedAt: now }) + .where(and(eq(chatConversations.id, id), isNull(chatConversations.deletedAt))) + .returning(); + if (!row) throw notFound("Conversation not found"); + return row; + }, + + async listMessages( + conversationId: string, + opts: { cursor?: string; limit?: number }, + ) { + const limit = Math.min(opts.limit ?? 50, 200); + + const conditions = [eq(chatMessages.conversationId, conversationId)]; + + if (opts.cursor) { + conditions.push(lt(chatMessages.createdAt, new Date(opts.cursor))); + } + + const rows = await db + .select() + .from(chatMessages) + .where(and(...conditions)) + .orderBy(desc(chatMessages.createdAt)) + .limit(limit + 1); + + const hasMore = rows.length > limit; + const items = hasMore ? rows.slice(0, limit) : rows; + const nextCursor = hasMore ? items[items.length - 1]!.createdAt.toISOString() : null; + + return { items, hasMore, nextCursor }; + }, + + async addMessage( + conversationId: string, + data: { role: string; content: string; agentId?: string }, + ) { + const [message] = await db + .insert(chatMessages) + .values({ + conversationId, + role: data.role, + content: data.content, + agentId: data.agentId ?? null, + }) + .returning(); + + // Pitfall 3: Bump conversation updatedAt after inserting a message + await db + .update(chatConversations) + .set({ updatedAt: new Date() }) + .where(eq(chatConversations.id, conversationId)); + + // Pitfall 5: Auto-title from first user message (idempotent via IS NULL guard) + if (data.role === "user") { + await db + .update(chatConversations) + .set({ title: data.content.slice(0, 60), updatedAt: new Date() }) + .where(and(eq(chatConversations.id, conversationId), isNull(chatConversations.title))); + } + + return message!; + }, + }; +}