16 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 21-chat-foundation | 03 | execute | 2 |
|
|
true |
|
|
Purpose: Provide the backend for conversation CRUD (create, list, update, pin, archive, soft-delete) and message CRUD (create, list) with cursor-based pagination. This is the data layer the UI consumes. Output: Working API endpoints mounted on the Express app.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/21-chat-foundation/21-RESEARCH.md @.planning/phases/21-chat-foundation/21-01-SUMMARY.md From packages/db/src/schema/chat_conversations.ts (created in Plan 01): ```typescript 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(), }); ```From packages/db/src/schema/chat_messages.ts (created in Plan 01):
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(),
});
From packages/shared/src/validators/chat.ts (created in Plan 01):
export const createConversationSchema = z.object({ title: z.string().max(200).optional(), agentId: z.string().uuid().optional() });
export const updateConversationSchema = z.object({ title: z.string().max(200).optional(), agentId: z.string().uuid().nullable().optional(), pinnedAt: z.string().datetime().nullable().optional(), archivedAt: z.string().datetime().nullable().optional() });
export const createMessageSchema = z.object({ role: z.enum(["user", "assistant", "system"]), content: z.string().min(1).max(100_000), agentId: z.string().uuid().optional() });
From server/src/routes/authz.ts:
export function assertBoard(req: Request): void;
export function assertCompanyAccess(req: Request, companyId: string): void;
From server/src/errors.ts:
export function notFound(message?: string): HttpError;
export function unprocessable(message: string, issues?: unknown): HttpError;
Task 1: Create chat service with CRUD operations
server/src/services/chat.ts
- server/src/services/documents.ts (reference for service factory pattern, Drizzle query patterns)
- server/src/services/activity.ts (reference for simpler service pattern)
- packages/db/src/schema/chat_conversations.ts (table definition — will exist from Plan 01)
- packages/db/src/schema/chat_messages.ts (table definition — will exist from Plan 01)
Create `server/src/services/chat.ts` following the `function chatService(db: Db)` factory pattern:
import { and, asc, desc, eq, isNull, lt, sql, count } 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 }) {
// ...
},
async createConversation(companyId: string, data: { title?: string; agentId?: string }) {
// ...
},
async getConversation(id: string) {
// ...
},
async updateConversation(id: string, data: { title?: string; agentId?: string | null; pinnedAt?: string | null; archivedAt?: string | null }) {
// ...
},
async softDeleteConversation(id: string) {
// ...
},
async listMessages(conversationId: string, opts: { cursor?: string; limit?: number }) {
// ...
},
async addMessage(conversationId: string, data: { role: string; content: string; agentId?: string }) {
// ...
},
};
}
Implementation details for each method:
listConversations:
limitdefaults to 30, max 100:const limit = Math.min(opts.limit ?? 30, 100);- Filter:
companyIdmatches,deletedAt IS NULL. IfincludeArchivedis false (default), also filterarchivedAt IS NULL. - Cursor: if
opts.cursorprovided, addlt(chatConversations.updatedAt, new Date(opts.cursor)) - Order:
desc(chatConversations.updatedAt) - Pagination: fetch
limit + 1, ifrows.length > limitthenhasMore = true, returnrows.slice(0, limit) - Also do a lateral subquery or second query to get
lastMessagePreview: for each conversation, get the most recent message content truncated to 100 chars. If that's too complex, returnlastMessagePreview: nullfor now and add it in a follow-up.
createConversation:
- Insert into
chatConversationswithcompanyId, optionaltitle, optionalagentId - Return the inserted row
getConversation:
- Select where
idmatches anddeletedAt IS NULL - Throw
notFound("Conversation not found")if no row
updateConversation:
- Build a partial update object from provided fields
- For
pinnedAtandarchivedAt: if value is a string, convert tonew Date(value). If value isnull, set column tonull. - Also set
updatedAt: new Date()on every update - Use
RETURNING *to get the updated row
softDeleteConversation:
UPDATE chat_conversations SET deleted_at = now(), updated_at = now() WHERE id = $id AND deleted_at IS NULL- Return the updated row or throw notFound
listMessages:
limitdefaults to 50, max 200- Filter:
conversationIdmatches - Cursor: if
opts.cursorprovided, addlt(chatMessages.createdAt, new Date(opts.cursor)) - Order:
desc(chatMessages.createdAt)(most recent first) - Same pagination pattern as conversations
addMessage:
- Insert into
chatMessageswithconversationId,role,content, optionalagentId - CRITICAL (Pitfall 3 from RESEARCH.md): After inserting the message, also UPDATE the conversation's
updatedAttonow():await db.update(chatConversations) .set({ updatedAt: new Date() }) .where(eq(chatConversations.id, conversationId)); - CRITICAL (Pitfall 5 from RESEARCH.md): Auto-title generation — if this is the first message (role === "user") and the conversation has no title:
Useawait db.update(chatConversations) .set({ title: data.content.slice(0, 60), updatedAt: new Date() }) .where(and(eq(chatConversations.id, conversationId), isNull(chatConversations.title)));WHERE title IS NULLto make it idempotent. - Return the inserted message row
cd /opt/nexus && grep -q "export function chatService" server/src/services/chat.ts && grep -q "listConversations" server/src/services/chat.ts && grep -q "addMessage" server/src/services/chat.ts && grep -q "isNull(chatConversations.title)" server/src/services/chat.ts && echo "OK"
<acceptance_criteria>
- server/src/services/chat.ts contains
export function chatService(db: Db) - Contains methods: listConversations, createConversation, getConversation, updateConversation, softDeleteConversation, listMessages, addMessage
- addMessage updates
chatConversations.updatedAtafter inserting message (Pitfall 3) - addMessage auto-sets title when
title IS NULLusingdata.content.slice(0, 60)(Pitfall 5) - listConversations filters
isNull(chatConversations.deletedAt) - listConversations uses
desc(chatConversations.updatedAt)ordering - Pagination uses
limit + 1pattern withhasMoreboolean </acceptance_criteria> Chat service exports a factory function with 7 CRUD methods covering conversation lifecycle (create, list, get, update, soft-delete) and message operations (list, add), including auto-title generation and updatedAt bumping.
- server/src/services/chat.ts contains
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { chatService } from "../services/chat.js";
import { createConversationSchema, updateConversationSchema, createMessageSchema } from "@paperclipai/shared";
export function chatRoutes(db: Db): Router {
const router = Router();
const svc = chatService(db);
// GET /api/companies/:companyId/conversations
router.get("/companies/:companyId/conversations", async (req, res) => {
assertBoard(req);
assertCompanyAccess(req, req.params.companyId!);
const { cursor, limit, includeArchived } = req.query;
const result = await svc.listConversations(req.params.companyId!, {
cursor: cursor as string | undefined,
limit: limit ? Number(limit) : undefined,
includeArchived: includeArchived === "true",
});
res.json(result);
});
// POST /api/companies/:companyId/conversations
router.post("/companies/:companyId/conversations", async (req, res) => {
assertBoard(req);
assertCompanyAccess(req, req.params.companyId!);
const data = createConversationSchema.parse(req.body);
const conversation = await svc.createConversation(req.params.companyId!, data);
res.status(201).json(conversation);
});
// GET /api/conversations/:id
router.get("/conversations/:id", async (req, res) => {
assertBoard(req);
const conversation = await svc.getConversation(req.params.id!);
res.json(conversation);
});
// PATCH /api/conversations/:id
router.patch("/conversations/:id", async (req, res) => {
assertBoard(req);
const data = updateConversationSchema.parse(req.body);
const conversation = await svc.updateConversation(req.params.id!, data);
res.json(conversation);
});
// DELETE /api/conversations/:id
router.delete("/conversations/:id", async (req, res) => {
assertBoard(req);
await svc.softDeleteConversation(req.params.id!);
res.status(204).end();
});
// GET /api/conversations/:id/messages
router.get("/conversations/:id/messages", async (req, res) => {
assertBoard(req);
const { cursor, limit } = req.query;
const result = await svc.listMessages(req.params.id!, {
cursor: cursor as string | undefined,
limit: limit ? Number(limit) : undefined,
});
res.json(result);
});
// POST /api/conversations/:id/messages
router.post("/conversations/:id/messages", async (req, res) => {
assertBoard(req);
const data = createMessageSchema.parse(req.body);
const message = await svc.addMessage(req.params.id!, data);
res.status(201).json(message);
});
return router;
}
NOTE: Check if existing routes wrap async handlers with a try/catch or rely on Express 5's built-in async error handling. Express 5.1.0 natively handles rejected promises in async route handlers, so no manual try/catch wrapper is needed unless the existing pattern uses one.
Mount in server/src/app.ts:
- Add import at the top with the other route imports:
import { chatRoutes } from "./routes/chat.js"; - Add
api.use(chatRoutes(db));in the route mounting section, after theactivityRoutesline (around line 158). cd /opt/nexus && grep -q "chatRoutes" server/src/routes/chat.ts && grep -q "chatRoutes" server/src/app.ts && grep -q 'companies/:companyId/conversations' server/src/routes/chat.ts && grep -q 'conversations/:id/messages' server/src/routes/chat.ts && echo "OK" <acceptance_criteria>- server/src/routes/chat.ts contains
export function chatRoutes(db: Db): Router - server/src/routes/chat.ts contains route
GET /companies/:companyId/conversations - server/src/routes/chat.ts contains route
POST /companies/:companyId/conversations - server/src/routes/chat.ts contains route
GET /conversations/:id - server/src/routes/chat.ts contains route
PATCH /conversations/:id - server/src/routes/chat.ts contains route
DELETE /conversations/:id - server/src/routes/chat.ts contains route
GET /conversations/:id/messages - server/src/routes/chat.ts contains route
POST /conversations/:id/messages - server/src/routes/chat.ts calls
assertBoard(req)on every route - server/src/routes/chat.ts calls
assertCompanyAccess(req, req.params.companyId!)on company-scoped routes - server/src/app.ts contains
import { chatRoutes } from "./routes/chat.js" - server/src/app.ts contains
chatRoutes(db)</acceptance_criteria> Chat API routes are mounted on the Express app with 7 endpoints covering conversation CRUD and message CRUD, all gated by assertBoard auth.
- server/src/routes/chat.ts contains
<success_criteria>
- Chat service has 7 methods covering full conversation + message CRUD
- Chat routes mounted in app.ts with proper auth
- Pagination uses cursor-based approach with hasMore
- Auto-title and updatedAt bump implemented per RESEARCH.md pitfalls </success_criteria>