feat(22-01): DB schema, shared types, validators, service methods for streaming and editing
- Add editedContent and editedAt columns to chat_messages schema - Update ChatMessage type with editedContent/editedAt fields - Add editMessageSchema and streamMessageSchema validators - Add agentId to updateConversationSchema - Export new schemas from @paperclipai/shared - Add editMessage and getMessageHistory service methods - Update updateConversation to accept agentId field - Add PUT /conversations/:id/messages/:messageId route - Extend test coverage for agentId patching and message editing - Generate migration 0048 for editedContent/editedAt columns
This commit is contained in:
parent
e8c70d6c8d
commit
5db6fe7af7
10 changed files with 12240 additions and 4 deletions
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE "chat_messages" ADD COLUMN "edited_content" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "chat_messages" ADD COLUMN "edited_at" timestamp with time zone;
|
||||||
12109
packages/db/src/migrations/meta/0048_snapshot.json
Normal file
12109
packages/db/src/migrations/meta/0048_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -337,6 +337,13 @@
|
||||||
"when": 1775041195907,
|
"when": 1775041195907,
|
||||||
"tag": "0047_fixed_johnny_storm",
|
"tag": "0047_fixed_johnny_storm",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 48,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775047784481,
|
||||||
|
"tag": "0048_flat_stepford_cuckoos",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -9,6 +9,8 @@ export const chatMessages = pgTable(
|
||||||
role: text("role").notNull(),
|
role: text("role").notNull(),
|
||||||
content: text("content").notNull(),
|
content: text("content").notNull(),
|
||||||
agentId: uuid("agent_id"),
|
agentId: uuid("agent_id"),
|
||||||
|
editedContent: text("edited_content"),
|
||||||
|
editedAt: timestamp("edited_at", { withTimezone: true }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
|
|
|
||||||
|
|
@ -608,4 +608,6 @@ export {
|
||||||
createConversationSchema,
|
createConversationSchema,
|
||||||
updateConversationSchema,
|
updateConversationSchema,
|
||||||
createMessageSchema,
|
createMessageSchema,
|
||||||
|
editMessageSchema,
|
||||||
|
streamMessageSchema,
|
||||||
} from "./validators/chat.js";
|
} from "./validators/chat.js";
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ export interface ChatMessage {
|
||||||
role: "user" | "assistant" | "system";
|
role: "user" | "assistant" | "system";
|
||||||
content: string;
|
content: string;
|
||||||
agentId: string | null;
|
agentId: string | null;
|
||||||
|
editedContent: string | null;
|
||||||
|
editedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export const createConversationSchema = z.object({
|
||||||
|
|
||||||
export const updateConversationSchema = z.object({
|
export const updateConversationSchema = z.object({
|
||||||
title: z.string().max(200).optional(),
|
title: z.string().max(200).optional(),
|
||||||
|
agentId: z.string().uuid().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createMessageSchema = z.object({
|
export const createMessageSchema = z.object({
|
||||||
|
|
@ -13,3 +14,12 @@ export const createMessageSchema = z.object({
|
||||||
content: z.string().min(1),
|
content: z.string().min(1),
|
||||||
agentId: z.string().uuid().optional().nullable(),
|
agentId: z.string().uuid().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const editMessageSchema = z.object({
|
||||||
|
content: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const streamMessageSchema = z.object({
|
||||||
|
content: z.string().min(1),
|
||||||
|
agentId: z.string().uuid().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ const mockChatService = vi.hoisted(() => ({
|
||||||
unpinConversation: vi.fn(),
|
unpinConversation: vi.fn(),
|
||||||
listMessages: vi.fn(),
|
listMessages: vi.fn(),
|
||||||
addMessage: vi.fn(),
|
addMessage: vi.fn(),
|
||||||
|
editMessage: vi.fn(),
|
||||||
|
getMessageHistory: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../services/chat.js", () => ({
|
vi.mock("../services/chat.js", () => ({
|
||||||
|
|
@ -204,6 +206,8 @@ describe("chat routes", () => {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: "hello",
|
content: "hello",
|
||||||
agentId: null,
|
agentId: null,
|
||||||
|
editedContent: null,
|
||||||
|
editedAt: null,
|
||||||
createdAt: "2024-01-01T00:00:00.000Z",
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
};
|
};
|
||||||
mockChatService.addMessage.mockResolvedValue(message);
|
mockChatService.addMessage.mockResolvedValue(message);
|
||||||
|
|
@ -216,4 +220,64 @@ describe("chat routes", () => {
|
||||||
expect(res.body).toMatchObject({ id: "msg-1", content: "hello" });
|
expect(res.body).toMatchObject({ id: "msg-1", content: "hello" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/conversations/:id with agentId", () => {
|
||||||
|
it("returns 200 with agentId set", async () => {
|
||||||
|
const agentId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||||
|
const updated = {
|
||||||
|
id: "conv-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
title: "Test",
|
||||||
|
agentId,
|
||||||
|
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({ agentId });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ agentId });
|
||||||
|
expect(mockChatService.updateConversation).toHaveBeenCalledWith("conv-1", { agentId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/conversations/:id/messages/:messageId", () => {
|
||||||
|
it("returns 200 with editedContent set", async () => {
|
||||||
|
const editedMessage = {
|
||||||
|
id: "msg-1",
|
||||||
|
conversationId: "conv-1",
|
||||||
|
role: "user",
|
||||||
|
content: "original",
|
||||||
|
agentId: null,
|
||||||
|
editedContent: "edited",
|
||||||
|
editedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
mockChatService.editMessage.mockResolvedValue(editedMessage);
|
||||||
|
|
||||||
|
const res = await request(createApp())
|
||||||
|
.put("/api/conversations/conv-1/messages/msg-1")
|
||||||
|
.send({ content: "edited" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ editedContent: "edited", editedAt: expect.any(String) });
|
||||||
|
expect(mockChatService.editMessage).toHaveBeenCalledWith("msg-1", { content: "edited" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when message not found", async () => {
|
||||||
|
mockChatService.editMessage.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await request(createApp())
|
||||||
|
.put("/api/conversations/conv-1/messages/nonexistent")
|
||||||
|
.send({ content: "edited" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Db } from "@paperclipai/db";
|
||||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||||
import { chatService } from "../services/chat.js";
|
import { chatService } from "../services/chat.js";
|
||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import { createConversationSchema, updateConversationSchema, createMessageSchema } from "@paperclipai/shared";
|
import { createConversationSchema, updateConversationSchema, createMessageSchema, editMessageSchema, streamMessageSchema } from "@paperclipai/shared";
|
||||||
|
|
||||||
export function chatRoutes(db: Db) {
|
export function chatRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -97,5 +97,16 @@ export function chatRoutes(db: Db) {
|
||||||
res.status(201).json(message);
|
res.status(201).json(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT /api/conversations/:id/messages/:messageId
|
||||||
|
router.put("/conversations/:id/messages/:messageId", validate(editMessageSchema), async (req, res) => {
|
||||||
|
assertBoard(req);
|
||||||
|
const message = await svc.editMessage(req.params.messageId as string, req.body);
|
||||||
|
if (!message) {
|
||||||
|
res.status(404).json({ error: "Not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(message);
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { and, desc, eq, isNull, lt } from "drizzle-orm";
|
import { and, asc, desc, eq, isNull, lt } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { chatConversations, chatMessages } from "@paperclipai/db";
|
import { chatConversations, chatMessages } from "@paperclipai/db";
|
||||||
|
|
||||||
|
|
@ -55,11 +55,12 @@ export function chatService(db: Db) {
|
||||||
return row;
|
return row;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateConversation(id: string, data: { title?: string }) {
|
async updateConversation(id: string, data: { title?: string; agentId?: string | null }) {
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(chatConversations)
|
.update(chatConversations)
|
||||||
.set({
|
.set({
|
||||||
title: data.title,
|
...(data.title !== undefined ? { title: data.title } : {}),
|
||||||
|
...(data.agentId !== undefined ? { agentId: data.agentId } : {}),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(chatConversations.id, id))
|
.where(eq(chatConversations.id, id))
|
||||||
|
|
@ -141,6 +142,32 @@ export function chatService(db: Db) {
|
||||||
return { items, hasMore };
|
return { items, hasMore };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async editMessage(messageId: string, data: { content: string }) {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(chatMessages)
|
||||||
|
.set({
|
||||||
|
editedContent: data.content,
|
||||||
|
editedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(chatMessages.id, messageId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMessageHistory(conversationId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(chatMessages)
|
||||||
|
.where(eq(chatMessages.conversationId, conversationId))
|
||||||
|
.orderBy(asc(chatMessages.createdAt));
|
||||||
|
|
||||||
|
return rows.map((m) => ({
|
||||||
|
...m,
|
||||||
|
effectiveContent: m.editedContent ?? m.content,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
async addMessage(conversationId: string, data: { role: string; content: string; agentId?: string | null }) {
|
async addMessage(conversationId: string, data: { role: string; content: string; agentId?: string | null }) {
|
||||||
const [message] = await db
|
const [message] = await db
|
||||||
.insert(chatMessages)
|
.insert(chatMessages)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue