feat(21-01): DB schema, shared types, validators, and chat service with tests
- Add chat_conversations and chat_messages Drizzle schema tables - Generate migration 0047 with cascade FK and indexes - Add ChatConversation, ChatMessage, ChatConversationListResponse types - Add createConversationSchema, updateConversationSchema, createMessageSchema Zod validators - Implement chatService factory with CRUD, auto-title on first message, updatedAt bump - Add chat-service test suite (12 tests, all passing) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
c6ae93da52
commit
0152d95865
13 changed files with 12746 additions and 0 deletions
27
packages/db/src/migrations/0047_fixed_johnny_storm.sql
Normal file
27
packages/db/src/migrations/0047_fixed_johnny_storm.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
CREATE TABLE "chat_conversations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"title" text,
|
||||
"agent_id" uuid,
|
||||
"pinned_at" timestamp with time zone,
|
||||
"archived_at" timestamp with time zone,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "chat_messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"conversation_id" uuid NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"agent_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "chat_conversations" ADD CONSTRAINT "chat_conversations_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_conversations" ADD CONSTRAINT "chat_conversations_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_conversation_id_chat_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."chat_conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "chat_conversations_company_updated_idx" ON "chat_conversations" USING btree ("company_id","updated_at");--> statement-breakpoint
|
||||
CREATE INDEX "chat_conversations_company_deleted_idx" ON "chat_conversations" USING btree ("company_id","deleted_at");--> statement-breakpoint
|
||||
CREATE INDEX "chat_messages_conversation_created_idx" ON "chat_messages" USING btree ("conversation_id","created_at");
|
||||
12097
packages/db/src/migrations/meta/0047_snapshot.json
Normal file
12097
packages/db/src/migrations/meta/0047_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -330,6 +330,13 @@
|
|||
"when": 1774960197878,
|
||||
"tag": "0046_smooth_sentinels",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "7",
|
||||
"when": 1775041195907,
|
||||
"tag": "0047_fixed_johnny_storm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
22
packages/db/src/schema/chat_conversations.ts
Normal file
22
packages/db/src/schema/chat_conversations.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
|
||||
export const chatConversations = pgTable(
|
||||
"chat_conversations",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
title: text("title"),
|
||||
agentId: uuid("agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
pinnedAt: timestamp("pinned_at", { withTimezone: true }),
|
||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyUpdatedIdx: index("chat_conversations_company_updated_idx").on(table.companyId, table.updatedAt),
|
||||
companyDeletedIdx: index("chat_conversations_company_deleted_idx").on(table.companyId, table.deletedAt),
|
||||
}),
|
||||
);
|
||||
17
packages/db/src/schema/chat_messages.ts
Normal file
17
packages/db/src/schema/chat_messages.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
|
||||
import { chatConversations } from "./chat_conversations.js";
|
||||
|
||||
export const chatMessages = pgTable(
|
||||
"chat_messages",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
conversationId: uuid("conversation_id").notNull().references(() => chatConversations.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull(),
|
||||
content: text("content").notNull(),
|
||||
agentId: uuid("agent_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
conversationCreatedIdx: index("chat_messages_conversation_created_idx").on(table.conversationId, table.createdAt),
|
||||
}),
|
||||
);
|
||||
|
|
@ -56,3 +56,5 @@ export { pluginEntities } from "./plugin_entities.js";
|
|||
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
|
||||
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
|
||||
export { pluginLogs } from "./plugin_logs.js";
|
||||
export { chatConversations } from "./chat_conversations.js";
|
||||
export { chatMessages } from "./chat_messages.js";
|
||||
|
|
|
|||
|
|
@ -601,3 +601,11 @@ export {
|
|||
type SecretsLocalEncryptedConfig,
|
||||
type ConfigMeta,
|
||||
} from "./config-schema.js";
|
||||
|
||||
export type { ChatConversation, ChatMessage, ChatConversationListResponse } from "./types/chat.js";
|
||||
|
||||
export {
|
||||
createConversationSchema,
|
||||
updateConversationSchema,
|
||||
createMessageSchema,
|
||||
} from "./validators/chat.js";
|
||||
|
|
|
|||
25
packages/shared/src/types/chat.ts
Normal file
25
packages/shared/src/types/chat.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export interface ChatConversation {
|
||||
id: string;
|
||||
companyId: string;
|
||||
title: string | null;
|
||||
agentId: string | null;
|
||||
pinnedAt: string | null;
|
||||
archivedAt: string | null;
|
||||
deletedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
agentId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ChatConversationListResponse {
|
||||
items: ChatConversation[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
|
@ -179,6 +179,7 @@ export type {
|
|||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityExportRequest,
|
||||
} from "./company-portability.js";
|
||||
export type { ChatConversation, ChatMessage, ChatConversationListResponse } from "./chat.js";
|
||||
export type {
|
||||
JsonSchema,
|
||||
PluginJobDeclaration,
|
||||
|
|
|
|||
15
packages/shared/src/validators/chat.ts
Normal file
15
packages/shared/src/validators/chat.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const createConversationSchema = z.object({
|
||||
title: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export const updateConversationSchema = z.object({
|
||||
title: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export const createMessageSchema = z.object({
|
||||
role: z.enum(["user", "assistant", "system"]),
|
||||
content: z.string().min(1),
|
||||
agentId: z.string().uuid().optional().nullable(),
|
||||
});
|
||||
|
|
@ -228,6 +228,12 @@ export {
|
|||
type CreateFinanceEvent,
|
||||
} from "./finance.js";
|
||||
|
||||
export {
|
||||
createConversationSchema,
|
||||
updateConversationSchema,
|
||||
createMessageSchema,
|
||||
} from "./chat.js";
|
||||
|
||||
export {
|
||||
createAssetImageMetadataSchema,
|
||||
type CreateAssetImageMetadata,
|
||||
|
|
|
|||
341
server/src/__tests__/chat-service.test.ts
Normal file
341
server/src/__tests__/chat-service.test.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the db module
|
||||
const mockReturning = vi.fn();
|
||||
const mockWhere = vi.fn();
|
||||
const mockOrderBy = vi.fn();
|
||||
const mockLimit = vi.fn();
|
||||
const mockSet = vi.fn();
|
||||
const mockValues = vi.fn();
|
||||
const mockFrom = vi.fn();
|
||||
const mockSelect = vi.fn();
|
||||
const mockInsert = vi.fn();
|
||||
const mockUpdate = vi.fn();
|
||||
|
||||
vi.mock("@paperclipai/db", async () => {
|
||||
const mod = await vi.importActual("@paperclipai/db");
|
||||
return {
|
||||
...mod,
|
||||
chatConversations: {
|
||||
id: "chatConversations.id",
|
||||
companyId: "chatConversations.companyId",
|
||||
title: "chatConversations.title",
|
||||
agentId: "chatConversations.agentId",
|
||||
pinnedAt: "chatConversations.pinnedAt",
|
||||
archivedAt: "chatConversations.archivedAt",
|
||||
deletedAt: "chatConversations.deletedAt",
|
||||
createdAt: "chatConversations.createdAt",
|
||||
updatedAt: "chatConversations.updatedAt",
|
||||
},
|
||||
chatMessages: {
|
||||
id: "chatMessages.id",
|
||||
conversationId: "chatMessages.conversationId",
|
||||
role: "chatMessages.role",
|
||||
content: "chatMessages.content",
|
||||
agentId: "chatMessages.agentId",
|
||||
createdAt: "chatMessages.createdAt",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Import service after mock is set up
|
||||
import { chatService } from "../services/chat.js";
|
||||
|
||||
function makeConversation(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "conv-1",
|
||||
companyId: "company-1",
|
||||
title: "Test Conversation",
|
||||
agentId: null,
|
||||
pinnedAt: null,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2024-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessage(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "msg-1",
|
||||
conversationId: "conv-1",
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
agentId: null,
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Create a chainable mock db
|
||||
function createMockDb() {
|
||||
const chainable: Record<string, unknown> = {};
|
||||
|
||||
// Build a chainable mock that returns itself until .then is called
|
||||
function makeChain(finalResult: unknown[] | null = null): Record<string, unknown> {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = ["select", "from", "where", "orderBy", "limit", "insert", "values", "returning", "update", "set"];
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn().mockReturnValue(chain);
|
||||
}
|
||||
if (finalResult !== null) {
|
||||
(chain as { then: unknown }).then = (resolve: (v: unknown) => unknown) => Promise.resolve(resolve(finalResult));
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
return makeChain;
|
||||
}
|
||||
|
||||
describe("chatService", () => {
|
||||
let db: ReturnType<typeof createRealMockDb>;
|
||||
|
||||
function createRealMockDb() {
|
||||
return {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = createRealMockDb();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createConversation", () => {
|
||||
it("inserts a row and returns it with id, companyId, title, createdAt, updatedAt", async () => {
|
||||
const created = makeConversation();
|
||||
const returningMock = vi.fn().mockResolvedValue([created]);
|
||||
const valuesMock = vi.fn().mockReturnValue({ returning: returningMock });
|
||||
db.insert = vi.fn().mockReturnValue({ values: valuesMock });
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.createConversation("company-1", { title: "Test Conversation" });
|
||||
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
expect(valuesMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
title: "Test Conversation",
|
||||
}));
|
||||
expect(result).toMatchObject({
|
||||
id: "conv-1",
|
||||
companyId: "company-1",
|
||||
title: "Test Conversation",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("listConversations", () => {
|
||||
it("returns items sorted by updatedAt DESC, excludes soft-deleted rows", async () => {
|
||||
const conversations = [makeConversation(), makeConversation({ id: "conv-2" })];
|
||||
const limitMock = vi.fn().mockResolvedValue(conversations);
|
||||
const orderByMock = vi.fn().mockReturnValue({ limit: limitMock });
|
||||
const whereMock = vi.fn().mockReturnValue({ orderBy: orderByMock });
|
||||
const fromMock = vi.fn().mockReturnValue({ where: whereMock });
|
||||
db.select = vi.fn().mockReturnValue({ from: fromMock });
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.listConversations("company-1", {});
|
||||
|
||||
expect(db.select).toHaveBeenCalled();
|
||||
expect(result).toEqual({ items: conversations, hasMore: false });
|
||||
});
|
||||
|
||||
it("returns hasMore=true when more rows exist beyond limit", async () => {
|
||||
const limit = 2;
|
||||
// Return limit + 1 rows to signal hasMore
|
||||
const rows = [makeConversation(), makeConversation({ id: "conv-2" }), makeConversation({ id: "conv-3" })];
|
||||
const limitMock = vi.fn().mockResolvedValue(rows);
|
||||
const orderByMock = vi.fn().mockReturnValue({ limit: limitMock });
|
||||
const whereMock = vi.fn().mockReturnValue({ orderBy: orderByMock });
|
||||
const fromMock = vi.fn().mockReturnValue({ where: whereMock });
|
||||
db.select = vi.fn().mockReturnValue({ from: fromMock });
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.listConversations("company-1", { limit });
|
||||
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(result.items).toHaveLength(limit);
|
||||
});
|
||||
|
||||
it("with cursor returns only rows older than cursor", async () => {
|
||||
const cursor = "2024-01-01T00:00:00Z";
|
||||
const conversations = [makeConversation()];
|
||||
const limitMock = vi.fn().mockResolvedValue(conversations);
|
||||
const orderByMock = vi.fn().mockReturnValue({ limit: limitMock });
|
||||
const whereMock = vi.fn().mockReturnValue({ orderBy: orderByMock });
|
||||
const fromMock = vi.fn().mockReturnValue({ where: whereMock });
|
||||
db.select = vi.fn().mockReturnValue({ from: fromMock });
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.listConversations("company-1", { cursor });
|
||||
|
||||
// The where clause should use the cursor
|
||||
expect(whereMock).toHaveBeenCalled();
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addMessage", () => {
|
||||
it("inserts message and bumps conversation.updatedAt", async () => {
|
||||
const message = makeMessage();
|
||||
const returningMock = vi.fn().mockResolvedValue([message]);
|
||||
const valuesMock = vi.fn().mockReturnValue({ returning: returningMock });
|
||||
db.insert = vi.fn().mockReturnValue({ values: valuesMock });
|
||||
|
||||
// update chain for bumping updatedAt and setting title
|
||||
const whereUpdateMock = vi.fn().mockResolvedValue([]);
|
||||
const setMock = vi.fn().mockReturnValue({ where: whereUpdateMock });
|
||||
db.update = vi.fn().mockReturnValue({ set: setMock });
|
||||
|
||||
// select to get conversation (for title check)
|
||||
const thenMock = vi.fn().mockResolvedValue(makeConversation({ title: "Existing Title" }));
|
||||
const whereSelectMock = vi.fn().mockReturnValue({ then: thenMock });
|
||||
const fromMock = vi.fn().mockReturnValue({ where: whereSelectMock });
|
||||
db.select = vi.fn().mockReturnValue({ from: fromMock });
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.addMessage("conv-1", { role: "user", content: "Hello" });
|
||||
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ id: "msg-1", content: "Hello" });
|
||||
});
|
||||
|
||||
it("on a conversation with title=null sets title to first 60 chars of content", async () => {
|
||||
const message = makeMessage({ content: "This is the first message that should become the title" });
|
||||
const returningMock = vi.fn().mockResolvedValue([message]);
|
||||
const valuesMock = vi.fn().mockReturnValue({ returning: returningMock });
|
||||
db.insert = vi.fn().mockReturnValue({ values: valuesMock });
|
||||
|
||||
const whereUpdateMock = vi.fn().mockResolvedValue([]);
|
||||
const setMock = vi.fn().mockReturnValue({ where: whereUpdateMock });
|
||||
db.update = vi.fn().mockReturnValue({ set: setMock });
|
||||
|
||||
// Conversation has no title
|
||||
const thenMock = vi.fn().mockResolvedValue(makeConversation({ title: null }));
|
||||
const whereSelectMock = vi.fn().mockReturnValue({ then: thenMock });
|
||||
const fromMock = vi.fn().mockReturnValue({ where: whereSelectMock });
|
||||
db.select = vi.fn().mockReturnValue({ from: fromMock });
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.addMessage("conv-1", { role: "user", content: "This is the first message that should become the title" });
|
||||
|
||||
// update should be called at least twice: once for updatedAt, once for title
|
||||
expect(db.update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("on a conversation with existing title does NOT overwrite title", async () => {
|
||||
const message = makeMessage();
|
||||
const returningMock = vi.fn().mockResolvedValue([message]);
|
||||
const valuesMock = vi.fn().mockReturnValue({ returning: returningMock });
|
||||
db.insert = vi.fn().mockReturnValue({ values: valuesMock });
|
||||
|
||||
const whereUpdateMock = vi.fn().mockResolvedValue([]);
|
||||
const setMock = vi.fn().mockReturnValue({ where: whereUpdateMock });
|
||||
db.update = vi.fn().mockReturnValue({ set: setMock });
|
||||
|
||||
// Conversation has an existing title
|
||||
const thenMock = vi.fn().mockResolvedValue(makeConversation({ title: "Existing Title" }));
|
||||
const whereSelectMock = vi.fn().mockReturnValue({ then: thenMock });
|
||||
const fromMock = vi.fn().mockReturnValue({ where: whereSelectMock });
|
||||
db.select = vi.fn().mockReturnValue({ from: fromMock });
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.addMessage("conv-1", { role: "user", content: "New message" });
|
||||
|
||||
// update should be called once (only for updatedAt), not for title
|
||||
expect(db.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("softDeleteConversation", () => {
|
||||
it("sets deletedAt timestamp", async () => {
|
||||
const conv = makeConversation({ deletedAt: new Date() });
|
||||
const returningMock = vi.fn().mockResolvedValue([conv]);
|
||||
const whereUpdateMock = vi.fn().mockReturnValue({ returning: returningMock });
|
||||
const setMock = vi.fn().mockReturnValue({ where: whereUpdateMock });
|
||||
db.update = vi.fn().mockReturnValue({ set: setMock });
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.softDeleteConversation("conv-1");
|
||||
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
expect(setMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
deletedAt: expect.any(Date),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveConversation", () => {
|
||||
it("sets archivedAt timestamp", async () => {
|
||||
const conv = makeConversation({ archivedAt: new Date() });
|
||||
const returningMock = vi.fn().mockResolvedValue([conv]);
|
||||
const whereUpdateMock = vi.fn().mockReturnValue({ returning: returningMock });
|
||||
const setMock = vi.fn().mockReturnValue({ where: whereUpdateMock });
|
||||
db.update = vi.fn().mockReturnValue({ set: setMock });
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.archiveConversation("conv-1");
|
||||
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
expect(setMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
archivedAt: expect.any(Date),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinConversation", () => {
|
||||
it("sets pinnedAt timestamp", async () => {
|
||||
const conv = makeConversation({ pinnedAt: new Date() });
|
||||
const returningMock = vi.fn().mockResolvedValue([conv]);
|
||||
const whereUpdateMock = vi.fn().mockReturnValue({ returning: returningMock });
|
||||
const setMock = vi.fn().mockReturnValue({ where: whereUpdateMock });
|
||||
db.update = vi.fn().mockReturnValue({ set: setMock });
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.pinConversation("conv-1");
|
||||
|
||||
expect(setMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
pinnedAt: expect.any(Date),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("unpinConversation", () => {
|
||||
it("clears pinnedAt to null", async () => {
|
||||
const conv = makeConversation({ pinnedAt: null });
|
||||
const returningMock = vi.fn().mockResolvedValue([conv]);
|
||||
const whereUpdateMock = vi.fn().mockReturnValue({ returning: returningMock });
|
||||
const setMock = vi.fn().mockReturnValue({ where: whereUpdateMock });
|
||||
db.update = vi.fn().mockReturnValue({ set: setMock });
|
||||
|
||||
const svc = chatService(db as any);
|
||||
await svc.unpinConversation("conv-1");
|
||||
|
||||
expect(setMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
pinnedAt: null,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConversation", () => {
|
||||
it("updates title", async () => {
|
||||
const conv = makeConversation({ title: "New Title" });
|
||||
const returningMock = vi.fn().mockResolvedValue([conv]);
|
||||
const whereUpdateMock = vi.fn().mockReturnValue({ returning: returningMock });
|
||||
const setMock = vi.fn().mockReturnValue({ where: whereUpdateMock });
|
||||
db.update = vi.fn().mockReturnValue({ set: setMock });
|
||||
|
||||
const svc = chatService(db as any);
|
||||
const result = await svc.updateConversation("conv-1", { title: "New Title" });
|
||||
|
||||
expect(setMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: "New Title",
|
||||
}));
|
||||
expect(result).toMatchObject({ title: "New Title" });
|
||||
});
|
||||
});
|
||||
});
|
||||
178
server/src/services/chat.ts
Normal file
178
server/src/services/chat.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { and, desc, eq, isNull, lt } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { chatConversations, chatMessages } from "@paperclipai/db";
|
||||
|
||||
const DEFAULT_LIMIT = 30;
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
export function chatService(db: Db) {
|
||||
return {
|
||||
async listConversations(companyId: string, opts: { cursor?: string; limit?: number }) {
|
||||
const limit = Math.min(opts.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
||||
const fetchLimit = limit + 1; // fetch one extra to determine hasMore
|
||||
|
||||
const conditions = [
|
||||
eq(chatConversations.companyId, companyId),
|
||||
isNull(chatConversations.deletedAt),
|
||||
];
|
||||
|
||||
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(fetchLimit);
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const items = rows.slice(0, limit);
|
||||
|
||||
return { items, hasMore };
|
||||
},
|
||||
|
||||
async createConversation(companyId: string, data: { title?: string }) {
|
||||
const [conversation] = await db
|
||||
.insert(chatConversations)
|
||||
.values({
|
||||
companyId,
|
||||
title: data.title ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return conversation!;
|
||||
},
|
||||
|
||||
async getConversation(id: string) {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(eq(chatConversations.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
async updateConversation(id: string, data: { title?: string }) {
|
||||
const [updated] = await db
|
||||
.update(chatConversations)
|
||||
.set({
|
||||
title: data.title,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatConversations.id, id))
|
||||
.returning();
|
||||
|
||||
return updated!;
|
||||
},
|
||||
|
||||
async softDeleteConversation(id: string) {
|
||||
const [updated] = await db
|
||||
.update(chatConversations)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(chatConversations.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
async archiveConversation(id: string) {
|
||||
const [updated] = await db
|
||||
.update(chatConversations)
|
||||
.set({ archivedAt: new Date() })
|
||||
.where(eq(chatConversations.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
async unarchiveConversation(id: string) {
|
||||
const [updated] = await db
|
||||
.update(chatConversations)
|
||||
.set({ archivedAt: null })
|
||||
.where(eq(chatConversations.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
async pinConversation(id: string) {
|
||||
const [updated] = await db
|
||||
.update(chatConversations)
|
||||
.set({ pinnedAt: new Date() })
|
||||
.where(eq(chatConversations.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
async unpinConversation(id: string) {
|
||||
const [updated] = await db
|
||||
.update(chatConversations)
|
||||
.set({ pinnedAt: null })
|
||||
.where(eq(chatConversations.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
async listMessages(conversationId: string, opts: { cursor?: string; limit?: number }) {
|
||||
const limit = Math.min(opts.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
||||
const fetchLimit = limit + 1;
|
||||
|
||||
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(fetchLimit);
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const items = rows.slice(0, limit);
|
||||
|
||||
return { items, hasMore };
|
||||
},
|
||||
|
||||
async addMessage(conversationId: string, data: { role: string; content: string; agentId?: string | null }) {
|
||||
const [message] = await db
|
||||
.insert(chatMessages)
|
||||
.values({
|
||||
conversationId,
|
||||
role: data.role,
|
||||
content: data.content,
|
||||
agentId: data.agentId ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Bump conversation.updatedAt
|
||||
await db
|
||||
.update(chatConversations)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(chatConversations.id, conversationId));
|
||||
|
||||
// Auto-set title if conversation has no title (idempotent: only set if title IS NULL)
|
||||
const conversation = await db
|
||||
.select()
|
||||
.from(chatConversations)
|
||||
.where(eq(chatConversations.id, conversationId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (conversation && conversation.title === null) {
|
||||
await db
|
||||
.update(chatConversations)
|
||||
.set({ title: data.content.slice(0, 60) })
|
||||
.where(and(eq(chatConversations.id, conversationId), isNull(chatConversations.title)));
|
||||
}
|
||||
|
||||
return message!;
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue