358 lines
16 KiB
Markdown
358 lines
16 KiB
Markdown
---
|
|
phase: 21-chat-foundation
|
|
plan: 03
|
|
type: execute
|
|
wave: 2
|
|
depends_on: ["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]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/phases/21-chat-foundation/21-RESEARCH.md
|
|
@.planning/phases/21-chat-foundation/21-01-SUMMARY.md
|
|
|
|
<interfaces>
|
|
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;
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create chat service with CRUD operations</name>
|
|
<files>server/src/services/chat.ts</files>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<action>
|
|
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
|
|
</action>
|
|
<verify>
|
|
<automated>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"</automated>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create chat routes and mount in app.ts</name>
|
|
<files>server/src/routes/chat.ts, server/src/app.ts</files>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<action>
|
|
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).
|
|
</action>
|
|
<verify>
|
|
<automated>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"</automated>
|
|
</verify>
|
|
<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>
|
|
<done>Chat API routes are mounted on the Express app with 7 endpoints covering conversation CRUD and message CRUD, all gated by assertBoard auth.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `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)
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/21-chat-foundation/21-03-SUMMARY.md`
|
|
</output>
|