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:
Mikkel Georgsen 2026-04-01 14:50:16 +02:00
parent e8c70d6c8d
commit 5db6fe7af7
10 changed files with 12240 additions and 4 deletions

View file

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

File diff suppressed because it is too large Load diff

View file

@ -337,6 +337,13 @@
"when": 1775041195907,
"tag": "0047_fixed_johnny_storm",
"breakpoints": true
},
{
"idx": 48,
"version": "7",
"when": 1775047784481,
"tag": "0048_flat_stepford_cuckoos",
"breakpoints": true
}
]
}

View file

@ -9,6 +9,8 @@ export const chatMessages = pgTable(
role: text("role").notNull(),
content: text("content").notNull(),
agentId: uuid("agent_id"),
editedContent: text("edited_content"),
editedAt: timestamp("edited_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({

View file

@ -608,4 +608,6 @@ export {
createConversationSchema,
updateConversationSchema,
createMessageSchema,
editMessageSchema,
streamMessageSchema,
} from "./validators/chat.js";

View file

@ -16,6 +16,8 @@ export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
agentId: string | null;
editedContent: string | null;
editedAt: string | null;
createdAt: string;
}

View file

@ -6,6 +6,7 @@ export const createConversationSchema = z.object({
export const updateConversationSchema = z.object({
title: z.string().max(200).optional(),
agentId: z.string().uuid().optional().nullable(),
});
export const createMessageSchema = z.object({
@ -13,3 +14,12 @@ export const createMessageSchema = z.object({
content: z.string().min(1),
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(),
});

View file

@ -16,6 +16,8 @@ const mockChatService = vi.hoisted(() => ({
unpinConversation: vi.fn(),
listMessages: vi.fn(),
addMessage: vi.fn(),
editMessage: vi.fn(),
getMessageHistory: vi.fn(),
}));
vi.mock("../services/chat.js", () => ({
@ -204,6 +206,8 @@ describe("chat routes", () => {
role: "user",
content: "hello",
agentId: null,
editedContent: null,
editedAt: null,
createdAt: "2024-01-01T00:00:00.000Z",
};
mockChatService.addMessage.mockResolvedValue(message);
@ -216,4 +220,64 @@ describe("chat routes", () => {
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);
});
});
});

View file

@ -3,7 +3,7 @@ import type { Db } from "@paperclipai/db";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { chatService } from "../services/chat.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) {
const router = Router();
@ -97,5 +97,16 @@ export function chatRoutes(db: Db) {
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;
}

View file

@ -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 { chatConversations, chatMessages } from "@paperclipai/db";
@ -55,11 +55,12 @@ export function chatService(db: Db) {
return row;
},
async updateConversation(id: string, data: { title?: string }) {
async updateConversation(id: string, data: { title?: string; agentId?: string | null }) {
const [updated] = await db
.update(chatConversations)
.set({
title: data.title,
...(data.title !== undefined ? { title: data.title } : {}),
...(data.agentId !== undefined ? { agentId: data.agentId } : {}),
updatedAt: new Date(),
})
.where(eq(chatConversations.id, id))
@ -141,6 +142,32 @@ export function chatService(db: Db) {
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 }) {
const [message] = await db
.insert(chatMessages)