nexus/.planning/phases/21-chat-foundation/21-03-PLAN.md

16 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
21-chat-foundation 03 execute 2
21-01
server/src/services/chat.ts
server/src/routes/chat.ts
server/src/app.ts
true
CHAT-04
CHAT-05
CHAT-06
truths artifacts key_links
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
path provides exports
server/src/services/chat.ts chatService factory with all CRUD methods
chatService
path provides exports
server/src/routes/chat.ts chatRoutes factory returning Express Router
chatRoutes
from to via pattern
server/src/routes/chat.ts server/src/services/chat.ts chatService(db) instantiation chatService(db)
from to via pattern
server/src/app.ts server/src/routes/chat.ts api.use(chatRoutes(db)) chatRoutes(db)
from to via pattern
server/src/services/chat.ts packages/db/src/schema/chat_conversations.ts Drizzle query on chatConversations table 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. 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:

  • 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():
    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:
    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 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.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 </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.
Task 2: Create chat routes and mount in app.ts server/src/routes/chat.ts, server/src/app.ts - 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) Create `server/src/routes/chat.ts`:
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). 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.
- `cd /opt/nexus && pnpm --filter @paperclipai/server exec -- tsc --noEmit` 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)

<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>
After completion, create `.planning/phases/21-chat-foundation/21-03-SUMMARY.md`