--- phase: 21-chat-foundation plan: 03 type: execute wave: 2 depends_on: ["21-00", "21-01"] files_modified: - server/src/services/chat.ts - server/src/routes/chat.ts - server/src/app.ts autonomous: true requirements: [CHAT-04, CHAT-05, CHAT-06, HIST-05] must_haves: truths: - "A user on any device on the network can create a conversation and retrieve it from another browser session" - "POST /api/companies/:companyId/conversations creates a conversation row in DB" - "GET /api/companies/:companyId/conversations returns paginated list sorted by updatedAt DESC" - "POST /api/conversations/:id/messages creates a message and bumps conversation updatedAt" - "First message on a title-less conversation auto-sets the title to the first 60 chars" - "PATCH /api/conversations/:id can set pinnedAt, archivedAt, and title" - "DELETE /api/conversations/:id soft-deletes by setting deletedAt" artifacts: - path: "server/src/services/chat.ts" provides: "chatService factory with all CRUD methods" exports: ["chatService"] - path: "server/src/routes/chat.ts" provides: "chatRoutes factory returning Express Router" exports: ["chatRoutes"] key_links: - from: "server/src/routes/chat.ts" to: "server/src/services/chat.ts" via: "chatService(db) instantiation" pattern: "chatService\\(db\\)" - from: "server/src/app.ts" to: "server/src/routes/chat.ts" via: "api.use(chatRoutes(db))" pattern: "chatRoutes\\(db\\)" - from: "server/src/services/chat.ts" to: "packages/db/src/schema/chat_conversations.ts" via: "Drizzle query on chatConversations table" pattern: "chatConversations" --- Build the server-side chat service and REST API routes. 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. HIST-05 (cross-device sync) is satisfied by these API endpoints being network-accessible. Output: Working API endpoints mounted on the Express app. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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): ```typescript 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): ```typescript 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: ```typescript export function assertBoard(req: Request): void; export function assertCompanyAccess(req: Request, companyId: string): void; ``` From server/src/errors.ts: ```typescript 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/__tests__/chat-service.test.ts - createConversation inserts a row and returns it with id + timestamps - listConversations returns items sorted by updatedAt DESC, excludes deleted, supports cursor pagination with hasMore - getConversation returns row by id, throws notFound for missing/deleted - updateConversation sets provided fields and bumps updatedAt - softDeleteConversation sets deletedAt, throws notFound if already deleted - addMessage inserts message, bumps conversation updatedAt, auto-sets title on first user message when title IS NULL - listMessages returns items sorted by createdAt DESC with cursor pagination - server/src/services/documents.ts (reference for service factory pattern, Drizzle query patterns) - server/src/services/activity.ts (reference for simpler service pattern) - server/src/__tests__/chat-service.test.ts (test stub from Plan 00 -- fill in test implementations) - 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) First, update `server/src/__tests__/chat-service.test.ts` to replace `.todo` stubs with real test implementations using the vi.mock pattern from activity-routes.test.ts. Mock the db object and verify: - createConversation calls db.insert with correct table and returns result - listConversations calls db.select with correct where/orderBy/limit - addMessage calls db.insert for the message AND db.update for conversation updatedAt - addMessage calls db.update with `isNull(title)` condition for auto-title Then create `server/src/services/chat.ts` following the `function chatService(db: Db)` factory pattern: ```typescript 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:** - `limit` defaults to 30, max 100: `const limit = Math.min(opts.limit ?? 30, 100);` - Filter: `companyId` matches, `deletedAt IS NULL`. If `includeArchived` is false (default), also filter `archivedAt IS NULL`. - Cursor: if `opts.cursor` provided, add `lt(chatConversations.updatedAt, new Date(opts.cursor))` - Order: `desc(chatConversations.updatedAt)` - Pagination: fetch `limit + 1`, if `rows.length > limit` then `hasMore = true`, return `rows.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, return `lastMessagePreview: null` for now and add it in a follow-up. **createConversation:** - Insert into `chatConversations` with `companyId`, optional `title`, optional `agentId` - Return the inserted row **getConversation:** - Select where `id` matches and `deletedAt IS NULL` - Throw `notFound("Conversation not found")` if no row **updateConversation:** - Build a partial update object from provided fields - For `pinnedAt` and `archivedAt`: if value is a string, convert to `new Date(value)`. If value is `null`, set column to `null`. - 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:** - `limit` defaults to 50, max 200 - Filter: `conversationId` matches - Cursor: if `opts.cursor` provided, add `lt(chatMessages.createdAt, new Date(opts.cursor))` - Order: `desc(chatMessages.createdAt)` (most recent first) - Same pagination pattern as conversations **addMessage:** - Insert into `chatMessages` with `conversationId`, `role`, `content`, optional `agentId` - CRITICAL (Pitfall 3 from RESEARCH.md): After inserting the message, also UPDATE the conversation's `updatedAt` to `now()`: ```typescript 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: ```typescript await db.update(chatConversations) .set({ title: data.content.slice(0, 60), updatedAt: new Date() }) .where(and(eq(chatConversations.id, conversationId), isNull(chatConversations.title))); ``` Use `WHERE title IS NULL` to make it idempotent. - Return the inserted message row Run tests after implementation: ```bash pnpm vitest run server/src/__tests__/chat-service.test.ts ``` cd /opt/nexus && pnpm vitest run server/src/__tests__/chat-service.test.ts 2>&1 | tail -5 - server/src/services/chat.ts contains `export function chatService(db: Db)` - Contains methods: listConversations, createConversation, getConversation, updateConversation, softDeleteConversation, listMessages, addMessage - addMessage updates `chatConversations.updatedAt` after inserting message (Pitfall 3) - addMessage auto-sets title when `title IS NULL` using `data.content.slice(0, 60)` (Pitfall 5) - listConversations filters `isNull(chatConversations.deletedAt)` - listConversations uses `desc(chatConversations.updatedAt)` ordering - Pagination uses `limit + 1` pattern with `hasMore` boolean - chat-service tests pass via vitest 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. Tests pass. Task 2: Create chat routes and mount in app.ts server/src/routes/chat.ts, server/src/app.ts, server/src/__tests__/chat-routes.test.ts - POST /companies/:companyId/conversations creates a conversation and returns 201 - GET /companies/:companyId/conversations returns paginated list - GET /conversations/:id returns conversation, 404 for missing - PATCH /conversations/:id updates fields - DELETE /conversations/:id soft-deletes and returns 204 - POST /conversations/:id/messages creates message and returns 201 - GET /conversations/:id/messages returns paginated messages - server/src/routes/activity.ts (reference route factory pattern: function activityRoutes(db: Db): Router) - server/src/routes/authz.ts (assertBoard, assertCompanyAccess imports) - server/src/app.ts (existing route mounting pattern: api.use(fooRoutes(db))) - server/src/middleware/validate.ts (if exists -- validation middleware pattern) - server/src/__tests__/chat-routes.test.ts (test stub from Plan 00 -- fill in test implementations) First, update `server/src/__tests__/chat-routes.test.ts` to replace `.todo` stubs with real test implementations using the supertest + vi.mock pattern from activity-routes.test.ts. Mock chatService, create an express app with mock actor middleware, and verify: - POST /companies/:companyId/conversations returns 201 with created conversation - GET /companies/:companyId/conversations returns 200 with list - GET /conversations/:id returns 200 - DELETE /conversations/:id returns 204 - POST /conversations/:id/messages returns 201 Then create `server/src/routes/chat.ts`: ```typescript 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`: 1. Add import at the top with the other route imports: `import { chatRoutes } from "./routes/chat.js";` 2. Add `api.use(chatRoutes(db));` in the route mounting section, after the `activityRoutes` line (around line 158). Run tests after implementation: ```bash pnpm vitest run server/src/__tests__/chat-routes.test.ts ``` cd /opt/nexus && pnpm vitest run server/src/__tests__/chat-routes.test.ts 2>&1 | tail -5 - server/src/routes/chat.ts contains `export function chatRoutes(db: Db): Router` - server/src/routes/chat.ts contains all 7 route handlers - 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)` - chat-routes tests pass via vitest Chat API routes are mounted on the Express app with 7 endpoints covering conversation CRUD and message CRUD, all gated by assertBoard auth. Tests pass. - `cd /opt/nexus && pnpm --filter @paperclipai/server exec -- tsc --noEmit` passes - `pnpm vitest run server/src/__tests__/chat-service.test.ts` passes - `pnpm vitest run server/src/__tests__/chat-routes.test.ts` passes - Routes follow the factory pattern used by all other route files - Service correctly handles all pitfalls from RESEARCH.md (updatedAt bump, auto-title, soft delete) - 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 - Both server test files pass After completion, create `.planning/phases/21-chat-foundation/21-03-SUMMARY.md`