4 plans across 3 waves for Chat Foundation phase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
512 lines
23 KiB
Markdown
512 lines
23 KiB
Markdown
---
|
|
phase: 21-chat-foundation
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- packages/db/src/schema/chat_conversations.ts
|
|
- packages/db/src/schema/chat_messages.ts
|
|
- packages/db/src/schema/index.ts
|
|
- packages/shared/src/types/chat.ts
|
|
- packages/shared/src/validators/chat.ts
|
|
- server/src/services/chat.ts
|
|
- server/src/routes/chat.ts
|
|
- server/src/routes/index.ts
|
|
- server/src/app.ts
|
|
- server/src/__tests__/chat-service.test.ts
|
|
- server/src/__tests__/chat-routes.test.ts
|
|
autonomous: true
|
|
requirements:
|
|
- HIST-01
|
|
- HIST-05
|
|
- HIST-06
|
|
- CHAT-04
|
|
- CHAT-05
|
|
- CHAT-06
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Conversations and messages are stored in PostgreSQL and survive server restarts"
|
|
- "Multiple conversations exist per company, listed sorted by updatedAt DESC"
|
|
- "First message on a conversation auto-generates a title from first 60 characters"
|
|
- "Conversations can be soft-deleted, archived, and pinned via timestamp columns"
|
|
- "Conversations are accessible from any device via the REST API"
|
|
artifacts:
|
|
- path: "packages/db/src/schema/chat_conversations.ts"
|
|
provides: "chat_conversations Drizzle table definition"
|
|
contains: "export const chatConversations"
|
|
- path: "packages/db/src/schema/chat_messages.ts"
|
|
provides: "chat_messages Drizzle table definition with cascade delete"
|
|
contains: "onDelete: \"cascade\""
|
|
- path: "packages/shared/src/types/chat.ts"
|
|
provides: "ChatConversation and ChatMessage TypeScript interfaces"
|
|
exports: ["ChatConversation", "ChatMessage"]
|
|
- path: "packages/shared/src/validators/chat.ts"
|
|
provides: "Zod schemas for create/update conversation and message"
|
|
exports: ["createConversationSchema", "createMessageSchema", "updateConversationSchema"]
|
|
- path: "server/src/services/chat.ts"
|
|
provides: "chatService factory with CRUD operations"
|
|
exports: ["chatService"]
|
|
- path: "server/src/routes/chat.ts"
|
|
provides: "chatRoutes factory mounting conversation and message endpoints"
|
|
exports: ["chatRoutes"]
|
|
- path: "server/src/__tests__/chat-service.test.ts"
|
|
provides: "Service-level unit tests"
|
|
min_lines: 80
|
|
- path: "server/src/__tests__/chat-routes.test.ts"
|
|
provides: "Route-level integration tests"
|
|
min_lines: 60
|
|
key_links:
|
|
- from: "server/src/routes/chat.ts"
|
|
to: "server/src/services/chat.ts"
|
|
via: "chatService(db) factory call"
|
|
pattern: "chatService\\(db\\)"
|
|
- from: "server/src/app.ts"
|
|
to: "server/src/routes/chat.ts"
|
|
via: "api.use(chatRoutes(db))"
|
|
pattern: "chatRoutes\\(db\\)"
|
|
- from: "packages/db/src/schema/index.ts"
|
|
to: "packages/db/src/schema/chat_conversations.ts"
|
|
via: "re-export"
|
|
pattern: "export.*chatConversations.*chat_conversations"
|
|
---
|
|
|
|
<objective>
|
|
Create the database schema, shared types, service layer, and REST API for chat conversations and messages.
|
|
|
|
Purpose: Establishes the persistence and API foundation that all UI components in subsequent plans depend on. Without this, no conversation can be created, stored, or retrieved.
|
|
Output: Two new Drizzle schema files, a migration, shared types + validators, service + route factories, and automated tests.
|
|
</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/STATE.md
|
|
@.planning/phases/21-chat-foundation/21-RESEARCH.md
|
|
|
|
<interfaces>
|
|
<!-- Existing patterns the executor must follow exactly -->
|
|
|
|
From packages/db/src/schema/documents.ts (reference pattern for new schema):
|
|
```typescript
|
|
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
|
|
import { companies } from "./companies.js";
|
|
|
|
export const documents = pgTable(
|
|
"documents",
|
|
{
|
|
id: uuid("id").primaryKey().defaultRandom(),
|
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
|
// ... columns
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
companyUpdatedIdx: index("documents_company_updated_idx").on(table.companyId, table.updatedAt),
|
|
}),
|
|
);
|
|
```
|
|
|
|
From server/src/routes/activity.ts (reference pattern for route factory):
|
|
```typescript
|
|
export function activityRoutes(db: Db) {
|
|
const router = Router();
|
|
const svc = activityService(db);
|
|
router.get("/companies/:companyId/activity", async (req, res) => {
|
|
assertBoard(req);
|
|
assertCompanyAccess(req, req.params.companyId!);
|
|
const result = await svc.list(filters);
|
|
res.json(result);
|
|
});
|
|
return router;
|
|
}
|
|
```
|
|
|
|
From server/src/__tests__/activity-routes.test.ts (reference test pattern):
|
|
```typescript
|
|
const mockActivityService = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
create: vi.fn(),
|
|
}));
|
|
vi.mock("../services/activity.js", () => ({
|
|
activityService: () => mockActivityService,
|
|
}));
|
|
function createApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = {
|
|
type: "board", userId: "user-1", companyIds: ["company-1"], source: "session", isInstanceAdmin: false,
|
|
};
|
|
next();
|
|
});
|
|
app.use("/api", activityRoutes({} as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
```
|
|
|
|
From server/src/middleware/validate.ts:
|
|
```typescript
|
|
export function validate(schema: ZodSchema) {
|
|
return (req: Request, _res: Response, next: NextFunction) => {
|
|
req.body = schema.parse(req.body);
|
|
next();
|
|
};
|
|
}
|
|
```
|
|
|
|
From server/src/app.ts (route mounting — line 158):
|
|
```typescript
|
|
api.use(activityRoutes(db));
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: DB schema, shared types, validators, and service with tests</name>
|
|
<files>
|
|
packages/db/src/schema/chat_conversations.ts,
|
|
packages/db/src/schema/chat_messages.ts,
|
|
packages/db/src/schema/index.ts,
|
|
packages/shared/src/types/chat.ts,
|
|
packages/shared/src/validators/chat.ts,
|
|
server/src/services/chat.ts,
|
|
server/src/__tests__/chat-service.test.ts
|
|
</files>
|
|
<read_first>
|
|
packages/db/src/schema/documents.ts,
|
|
packages/db/src/schema/index.ts,
|
|
packages/db/src/schema/companies.ts,
|
|
packages/db/src/schema/agents.ts,
|
|
packages/shared/src/types/company.ts,
|
|
packages/shared/src/validators/company.ts,
|
|
server/src/services/documents.ts,
|
|
server/src/__tests__/activity-routes.test.ts
|
|
</read_first>
|
|
<behavior>
|
|
- Test: chatService(db).createConversation({ companyId, title }) inserts a row and returns it with id, companyId, title, createdAt, updatedAt
|
|
- Test: chatService(db).listConversations(companyId, {}) returns items sorted by updatedAt DESC, excludes soft-deleted rows (deletedAt IS NOT NULL)
|
|
- Test: chatService(db).listConversations with cursor returns only rows older than cursor
|
|
- Test: chatService(db).listConversations returns hasMore=true when more rows exist beyond limit
|
|
- Test: chatService(db).addMessage({ conversationId, role, content }) inserts message and bumps conversation.updatedAt
|
|
- Test: chatService(db).addMessage on a conversation with title=null sets title to first 60 chars of content
|
|
- Test: chatService(db).addMessage on a conversation with existing title does NOT overwrite title
|
|
- Test: chatService(db).softDeleteConversation sets deletedAt timestamp
|
|
- Test: chatService(db).archiveConversation sets archivedAt timestamp
|
|
- Test: chatService(db).pinConversation sets pinnedAt timestamp
|
|
- Test: chatService(db).unpinConversation clears pinnedAt to null
|
|
- Test: chatService(db).updateConversation({ title }) updates title
|
|
</behavior>
|
|
<action>
|
|
1. Create `packages/db/src/schema/chat_conversations.ts`:
|
|
- Table name: `chat_conversations`
|
|
- Columns: `id` (uuid PK defaultRandom), `companyId` (uuid FK to companies.id, NOT NULL), `title` (text, nullable), `agentId` (uuid FK to agents.id onDelete "set null", nullable), `pinnedAt` (timestamp with tz, nullable), `archivedAt` (timestamp with tz, nullable), `deletedAt` (timestamp with tz, nullable), `createdAt` (timestamp with tz, NOT NULL, defaultNow), `updatedAt` (timestamp with tz, NOT NULL, defaultNow)
|
|
- Indexes: `chat_conversations_company_updated_idx` on (companyId, updatedAt), `chat_conversations_company_deleted_idx` on (companyId, deletedAt)
|
|
- Export: `export const chatConversations`
|
|
|
|
2. Create `packages/db/src/schema/chat_messages.ts`:
|
|
- Table name: `chat_messages`
|
|
- Columns: `id` (uuid PK defaultRandom), `conversationId` (uuid FK to chatConversations.id onDelete "cascade", NOT NULL), `role` (text NOT NULL — values: "user" | "assistant" | "system"), `content` (text NOT NULL), `agentId` (uuid, nullable — which agent produced this), `createdAt` (timestamp with tz, NOT NULL, defaultNow)
|
|
- Index: `chat_messages_conversation_created_idx` on (conversationId, createdAt)
|
|
- Export: `export const chatMessages`
|
|
|
|
3. Add to `packages/db/src/schema/index.ts` — append two lines:
|
|
```
|
|
export { chatConversations } from "./chat_conversations.js";
|
|
export { chatMessages } from "./chat_messages.js";
|
|
```
|
|
|
|
4. Run `pnpm db:generate` to generate migration SQL. Verify the generated SQL contains:
|
|
- `CREATE TABLE "chat_conversations"` with all columns
|
|
- `CREATE TABLE "chat_messages"` with `ON DELETE CASCADE` on conversation_id FK
|
|
- Both index `CREATE INDEX` statements
|
|
|
|
5. Create `packages/shared/src/types/chat.ts`:
|
|
```typescript
|
|
export interface ChatConversation {
|
|
id: string;
|
|
companyId: string;
|
|
title: string | null;
|
|
agentId: string | null;
|
|
pinnedAt: string | null;
|
|
archivedAt: string | null;
|
|
deletedAt: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface ChatMessage {
|
|
id: string;
|
|
conversationId: string;
|
|
role: "user" | "assistant" | "system";
|
|
content: string;
|
|
agentId: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface ChatConversationListResponse {
|
|
items: ChatConversation[];
|
|
hasMore: boolean;
|
|
}
|
|
```
|
|
|
|
6. Create `packages/shared/src/validators/chat.ts`:
|
|
```typescript
|
|
import { z } from "zod";
|
|
|
|
export const createConversationSchema = z.object({
|
|
title: z.string().max(200).optional(),
|
|
});
|
|
|
|
export const updateConversationSchema = z.object({
|
|
title: z.string().max(200).optional(),
|
|
});
|
|
|
|
export const createMessageSchema = z.object({
|
|
role: z.enum(["user", "assistant", "system"]),
|
|
content: z.string().min(1),
|
|
agentId: z.string().uuid().optional().nullable(),
|
|
});
|
|
```
|
|
|
|
7. Create `server/src/services/chat.ts` following the `documentService` factory pattern:
|
|
```typescript
|
|
export function chatService(db: Db) {
|
|
return {
|
|
async listConversations(companyId: string, opts: { cursor?: string; limit?: number }) { ... },
|
|
async createConversation(companyId: string, data: { title?: string }) { ... },
|
|
async getConversation(id: string) { ... },
|
|
async updateConversation(id: string, data: { title?: string }) { ... },
|
|
async softDeleteConversation(id: string) { ... },
|
|
async archiveConversation(id: string) { ... },
|
|
async unarchiveConversation(id: string) { ... },
|
|
async pinConversation(id: string) { ... },
|
|
async unpinConversation(id: string) { ... },
|
|
async listMessages(conversationId: string, opts: { cursor?: string; limit?: number }) { ... },
|
|
async addMessage(conversationId: string, data: { role: string; content: string; agentId?: string | null }) { ... },
|
|
};
|
|
}
|
|
```
|
|
Key implementation details:
|
|
- `listConversations`: filter `WHERE companyId = $1 AND deletedAt IS NULL`, order by `updatedAt DESC`, cursor-based pagination with `updatedAt < cursor`, limit defaults to 30 (max 100), return `{ items, hasMore }` where hasMore is `rows.length > limit` and items is `rows.slice(0, limit)`
|
|
- `addMessage`: after inserting the message, run `UPDATE chat_conversations SET updated_at = now() WHERE id = $conversationId`. Also, if `conversation.title IS NULL`, set `title = content.slice(0, 60)` using `WHERE id = $conversationId AND title IS NULL` for idempotency
|
|
- `softDeleteConversation`: `UPDATE chat_conversations SET deleted_at = now() WHERE id = $id`
|
|
- `archiveConversation`: `UPDATE SET archived_at = now()`
|
|
- `pinConversation`: `UPDATE SET pinned_at = now()`
|
|
- `unpinConversation`: `UPDATE SET pinned_at = null`
|
|
|
|
8. Create `server/src/__tests__/chat-service.test.ts` using the `vi.mock` pattern from `activity-routes.test.ts`. Mock `@paperclipai/db` to provide a mock `db` object with chainable `.select().from().where().orderBy().limit()` and `.insert().values().returning()` and `.update().set().where()` methods. Test all behaviors listed in the `<behavior>` block above.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run server/src/__tests__/chat-service.test.ts</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- packages/db/src/schema/chat_conversations.ts contains `export const chatConversations = pgTable("chat_conversations"`
|
|
- packages/db/src/schema/chat_conversations.ts contains `pinnedAt: timestamp("pinned_at"` and `archivedAt: timestamp("archived_at"` and `deletedAt: timestamp("deleted_at"`
|
|
- packages/db/src/schema/chat_messages.ts contains `onDelete: "cascade"`
|
|
- packages/db/src/schema/chat_messages.ts contains `role: text("role").notNull()`
|
|
- packages/db/src/schema/index.ts contains `export { chatConversations } from "./chat_conversations.js"`
|
|
- packages/db/src/schema/index.ts contains `export { chatMessages } from "./chat_messages.js"`
|
|
- packages/shared/src/types/chat.ts contains `export interface ChatConversation`
|
|
- packages/shared/src/types/chat.ts contains `export interface ChatMessage`
|
|
- packages/shared/src/validators/chat.ts contains `export const createConversationSchema`
|
|
- packages/shared/src/validators/chat.ts contains `export const createMessageSchema`
|
|
- server/src/services/chat.ts contains `export function chatService(db: Db)`
|
|
- server/src/services/chat.ts contains `async addMessage(`
|
|
- server/src/services/chat.ts contains `title IS NULL` or `isNull(chatConversations.title)` for idempotent title set
|
|
- server/src/__tests__/chat-service.test.ts exits 0
|
|
</acceptance_criteria>
|
|
<done>All schema files, types, validators, and service exist. Service test suite passes with coverage of list, create, add message (with auto-title), soft-delete, archive, pin/unpin.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: REST API routes and route tests</name>
|
|
<files>
|
|
server/src/routes/chat.ts,
|
|
server/src/routes/index.ts,
|
|
server/src/app.ts,
|
|
server/src/__tests__/chat-routes.test.ts
|
|
</files>
|
|
<read_first>
|
|
server/src/routes/activity.ts,
|
|
server/src/routes/index.ts,
|
|
server/src/routes/authz.ts,
|
|
server/src/app.ts,
|
|
server/src/__tests__/activity-routes.test.ts,
|
|
server/src/services/chat.ts,
|
|
packages/shared/src/validators/chat.ts
|
|
</read_first>
|
|
<behavior>
|
|
- Test: GET /api/companies/:companyId/conversations returns 200 with { items: [], hasMore: false } when empty
|
|
- Test: POST /api/companies/:companyId/conversations returns 201 with the created conversation object
|
|
- Test: GET /api/conversations/:id returns 200 with conversation object
|
|
- Test: PATCH /api/conversations/:id with { title: "new title" } returns 200 with updated conversation
|
|
- Test: DELETE /api/conversations/:id returns 204
|
|
- Test: POST /api/conversations/:id/archive returns 200
|
|
- Test: POST /api/conversations/:id/unarchive returns 200
|
|
- Test: POST /api/conversations/:id/pin returns 200
|
|
- Test: POST /api/conversations/:id/unpin returns 200
|
|
- Test: GET /api/conversations/:id/messages returns 200 with { items: [], hasMore: false }
|
|
- Test: POST /api/conversations/:id/messages with { role: "user", content: "hello" } returns 201
|
|
</behavior>
|
|
<action>
|
|
1. 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 { validate } from "../middleware/validate.js";
|
|
import { createConversationSchema, updateConversationSchema, createMessageSchema } from "@paperclipai/shared";
|
|
|
|
export function chatRoutes(db: Db) {
|
|
const router = Router();
|
|
const svc = chatService(db);
|
|
|
|
// GET /api/companies/:companyId/conversations
|
|
router.get("/companies/:companyId/conversations", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const cursor = req.query.cursor as string | undefined;
|
|
const limit = req.query.limit ? Number(req.query.limit) : undefined;
|
|
const result = await svc.listConversations(companyId, { cursor, limit });
|
|
res.json(result);
|
|
});
|
|
|
|
// POST /api/companies/:companyId/conversations
|
|
router.post("/companies/:companyId/conversations", validate(createConversationSchema), async (req, res) => {
|
|
assertBoard(req);
|
|
const companyId = req.params.companyId as string;
|
|
const conversation = await svc.createConversation(companyId, req.body);
|
|
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 as string);
|
|
if (!conversation) { res.status(404).json({ error: "Not found" }); return; }
|
|
res.json(conversation);
|
|
});
|
|
|
|
// PATCH /api/conversations/:id
|
|
router.patch("/conversations/:id", validate(updateConversationSchema), async (req, res) => {
|
|
assertBoard(req);
|
|
const conversation = await svc.updateConversation(req.params.id as string, req.body);
|
|
res.json(conversation);
|
|
});
|
|
|
|
// DELETE /api/conversations/:id
|
|
router.delete("/conversations/:id", async (req, res) => {
|
|
assertBoard(req);
|
|
await svc.softDeleteConversation(req.params.id as string);
|
|
res.status(204).end();
|
|
});
|
|
|
|
// POST /api/conversations/:id/archive
|
|
router.post("/conversations/:id/archive", async (req, res) => {
|
|
assertBoard(req);
|
|
const result = await svc.archiveConversation(req.params.id as string);
|
|
res.json(result);
|
|
});
|
|
|
|
// POST /api/conversations/:id/unarchive
|
|
router.post("/conversations/:id/unarchive", async (req, res) => {
|
|
assertBoard(req);
|
|
const result = await svc.unarchiveConversation(req.params.id as string);
|
|
res.json(result);
|
|
});
|
|
|
|
// POST /api/conversations/:id/pin
|
|
router.post("/conversations/:id/pin", async (req, res) => {
|
|
assertBoard(req);
|
|
const result = await svc.pinConversation(req.params.id as string);
|
|
res.json(result);
|
|
});
|
|
|
|
// POST /api/conversations/:id/unpin
|
|
router.post("/conversations/:id/unpin", async (req, res) => {
|
|
assertBoard(req);
|
|
const result = await svc.unpinConversation(req.params.id as string);
|
|
res.json(result);
|
|
});
|
|
|
|
// GET /api/conversations/:id/messages
|
|
router.get("/conversations/:id/messages", async (req, res) => {
|
|
assertBoard(req);
|
|
const cursor = req.query.cursor as string | undefined;
|
|
const limit = req.query.limit ? Number(req.query.limit) : undefined;
|
|
const result = await svc.listMessages(req.params.id as string, { cursor, limit });
|
|
res.json(result);
|
|
});
|
|
|
|
// POST /api/conversations/:id/messages
|
|
router.post("/conversations/:id/messages", validate(createMessageSchema), async (req, res) => {
|
|
assertBoard(req);
|
|
const message = await svc.addMessage(req.params.id as string, req.body);
|
|
res.status(201).json(message);
|
|
});
|
|
|
|
return router;
|
|
}
|
|
```
|
|
|
|
2. Add to `server/src/routes/index.ts`:
|
|
```
|
|
export { chatRoutes } from "./chat.js";
|
|
```
|
|
|
|
3. In `server/src/app.ts`, add import `import { chatRoutes } from "./routes/chat.js";` near line 27 (after activityRoutes import), and add `api.use(chatRoutes(db));` after line 158 (after `api.use(activityRoutes(db));`).
|
|
|
|
4. Create `server/src/__tests__/chat-routes.test.ts` following the exact `activity-routes.test.ts` mock pattern:
|
|
- Use `vi.hoisted` to create `mockChatService` with all methods as `vi.fn()`
|
|
- `vi.mock("../services/chat.js", () => ({ chatService: () => mockChatService }))`
|
|
- `createApp()` function that sets up express with JSON parsing, actor middleware (type: "board", companyIds: ["company-1"]), mounts `chatRoutes({} as any)` under `/api`, and adds `errorHandler`
|
|
- Test all behaviors listed in `<behavior>` block
|
|
- Also mock `@paperclipai/shared` validators if needed, or let them pass through (they are pure Zod schemas that work without mocking)
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Volumes/UsbNvme/repos/nexus && pnpm vitest run server/src/__tests__/chat-routes.test.ts</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- server/src/routes/chat.ts contains `export function chatRoutes(db: Db)`
|
|
- server/src/routes/chat.ts contains `router.get("/companies/:companyId/conversations"`
|
|
- server/src/routes/chat.ts contains `router.post("/conversations/:id/messages"`
|
|
- server/src/routes/chat.ts contains `assertBoard(req)` on every mutating route
|
|
- server/src/routes/chat.ts contains `assertCompanyAccess(req, companyId)` on the list endpoint
|
|
- server/src/routes/index.ts contains `export { chatRoutes } from "./chat.js"`
|
|
- server/src/app.ts contains `import { chatRoutes }` and `chatRoutes(db)`
|
|
- server/src/__tests__/chat-routes.test.ts exits 0
|
|
</acceptance_criteria>
|
|
<done>All chat REST endpoints exist and respond with correct status codes. Route test suite passes. Routes are mounted in app.ts and exported from routes/index.ts.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `pnpm vitest run server/src/__tests__/chat-service.test.ts` passes
|
|
- `pnpm vitest run server/src/__tests__/chat-routes.test.ts` passes
|
|
- `pnpm db:generate` produces migration SQL with both tables and cascade FK
|
|
- `grep -r "chatConversations\|chatMessages" packages/db/src/schema/index.ts` shows both exports
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Two new DB tables (chat_conversations, chat_messages) with correct columns, indexes, and cascade FK
|
|
- Shared types and Zod validators for all create/update operations
|
|
- Service layer with full CRUD + auto-title on first message + updatedAt bump
|
|
- REST API with 11 endpoints mounted in app.ts
|
|
- All automated tests green
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/21-chat-foundation/21-01-SUMMARY.md`
|
|
</output>
|