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:
Mikkel Georgsen 2026-04-01 13:01:28 +02:00
parent c6ae93da52
commit 0152d95865
13 changed files with 12746 additions and 0 deletions

View 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");

File diff suppressed because it is too large Load diff

View file

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

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

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

View file

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

View file

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

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

View file

@ -179,6 +179,7 @@ export type {
CompanyPortabilityImportResult,
CompanyPortabilityExportRequest,
} from "./company-portability.js";
export type { ChatConversation, ChatMessage, ChatConversationListResponse } from "./chat.js";
export type {
JsonSchema,
PluginJobDeclaration,

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

View file

@ -228,6 +228,12 @@ export {
type CreateFinanceEvent,
} from "./finance.js";
export {
createConversationSchema,
updateConversationSchema,
createMessageSchema,
} from "./chat.js";
export {
createAssetImageMetadataSchema,
type CreateAssetImageMetadata,

View 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
View 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!;
},
};
}