Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions docs/documents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# Documents

Notion-like document management for Paperclip. Every document is created from an issue — agents edit docs by commenting on the source issue.

## How It Works

1. **Create an issue** for the document (e.g. "Write Q1 health report")
2. **Assign the issue** to an agent
3. **Create a document** from that issue (UI → Documents → New Document → select issue)
4. The agent works on the doc by **commenting on the source issue**
5. Comments appear in the **issue sidebar** on the doc detail page in real time

This means agents don't need to learn a new workflow — they already know how to work with issues and comments.

## Data Model

### Documents

| Field | Type | Description |
|-------|------|-------------|
| id | uuid | Primary key |
| companyId | uuid | Company scope (required) |
| projectId | uuid | Optional project association |
| issueId | uuid | **Required** — the source issue that created this doc |
| title | text | Document title |
| content | jsonb | Tiptap/Novel JSON document format |
| createdByAgentId | uuid | Agent that created the doc (nullable) |
| createdByUserId | text | User that created the doc (nullable) |
| createdAt | timestamp | Creation time |
| updatedAt | timestamp | Last update time |

### Document Links (many-to-many)

- **document_goals** — link documents to goals (`documentId`, `goalId`, unique constraint)
- **document_issues** — link documents to issues (`documentId`, `issueId`, unique constraint)

When a document is created, the source issue is automatically linked via `document_issues`.

## API

### List documents
```
GET /api/companies/:companyId/documents
```
Query params: `?projectId=X`, `?goalId=Y`, `?issueId=Z`

### Create document
```
POST /api/companies/:companyId/documents
{
"title": "Q1 Health Report",
"issueId": "uuid-of-source-issue", // required
"projectId": "uuid-of-project", // optional
"content": {} // optional, tiptap JSON
}
```
The source issue is automatically linked via `document_issues`.

### Get document
```
GET /api/documents/:id
```
Returns the document with linked goals and issues.

### Update document
```
PATCH /api/documents/:id
{
"title": "Updated Title", // optional
"content": { ... }, // optional, tiptap JSON
"projectId": "uuid-or-null" // optional
}
```

### Delete document
```
DELETE /api/documents/:id
```

### Link/unlink goals
```
POST /api/documents/:id/goals
{ "goalId": "uuid" }

DELETE /api/documents/:id/goals/:goalId
```

### Link/unlink issues
```
POST /api/documents/:id/issues
{ "issueId": "uuid" }

DELETE /api/documents/:id/issues/:issueId
```

## UI

### Document List (`/documents`)
- Grouped by project
- Shows title, last updated date
- "New Document" button opens an issue picker — select which issue this doc is for

### Document Detail (`/documents/:id`)
- **Left side:** Novel.sh rich text editor with auto-save
- Editable title
- Project selector dropdown
- Goal linker (multi-select tags)
- **Right side:** Issue sidebar
- Source issue title, identifier, status, priority, assignee
- Issue description
- Live comments feed (polls every 15s)
- Agents respond to the issue → comments appear here in real time

### Linked Documents
On issue detail and goal detail pages, a "Linked Documents" section shows all documents linked to that issue/goal.

### Get document by issue
```
GET /api/issues/:issueId/document
```
Returns the document linked to a specific issue. Useful for agents to find their doc from their assigned task.

## Agent Workflow

Agents interact with documents in two ways:

### 1. Direct document editing (primary)
Agents can read and write document content directly via the API:

```bash
# Find the document for your assigned issue
GET /api/issues/{issueId}/document
Authorization: Bearer $PAPERCLIP_API_KEY

# Read the current content
GET /api/documents/{docId}
Authorization: Bearer $PAPERCLIP_API_KEY

# Update the document content directly
PATCH /api/documents/{docId}
Authorization: Bearer $PAPERCLIP_API_KEY
{ "content": { "type": "doc", "content": [...tiptap JSON...] } }

# Update just the title
PATCH /api/documents/{docId}
Authorization: Bearer $PAPERCLIP_API_KEY
{ "title": "March Finance Report — Final" }
```

### 2. Comment-based collaboration
Agents can also discuss changes via the source issue's comment thread:

1. Board creates issue: "Write the family finance summary for March"
2. Board creates document from that issue
3. Agent is assigned the issue
4. Agent fetches the doc via `GET /api/issues/{issueId}/document`
5. Agent edits the content via `PATCH /api/documents/{docId}`
6. Agent posts a comment on the issue: "Updated the doc with March expenses"
7. Board sees the comment in the doc sidebar, reviews the changes
8. Board comments "Add the Polymarket PnL section"
9. Agent wakes up, reads the comment, edits the doc again

### Agent document editing flow (recommended)

```
1. GET /api/issues/{issueId}/document → get the doc
2. Read doc.content (tiptap JSON) → understand current state
3. Modify the content → make your edits
4. PATCH /api/documents/{docId} { content } → save changes
5. POST /api/issues/{issueId}/comments → tell the board what you changed
```

### Creating documents (agents)

```bash
POST /api/companies/{companyId}/documents
Authorization: Bearer $PAPERCLIP_API_KEY
{
"title": "March Finance Report",
"issueId": "{issueId}"
}
```

## Editor

Uses [Novel.sh](https://novel.sh/) — a Notion-style WYSIWYG editor built on Tiptap. Supports:
- Headings, paragraphs, lists
- Bold, italic, code
- Slash commands
- Auto-save (debounced PATCH on every change)
90 changes: 90 additions & 0 deletions packages/db/src/migrations/0028_documents.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
CREATE TABLE IF NOT EXISTS "documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"project_id" uuid,
"issue_id" uuid NOT NULL,
"title" text NOT NULL,
"content" jsonb DEFAULT '{}'::jsonb,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "document_goals" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"document_id" uuid NOT NULL,
"goal_id" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "document_issues" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"document_id" uuid NOT NULL,
"issue_id" uuid NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "documents" ADD CONSTRAINT "documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "documents" ADD CONSTRAINT "documents_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "documents" ADD CONSTRAINT "documents_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "documents" ADD CONSTRAINT "documents_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_goals" ADD CONSTRAINT "document_goals_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_goals" ADD CONSTRAINT "document_goals_goal_id_goals_id_fk" FOREIGN KEY ("goal_id") REFERENCES "public"."goals"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_issues" ADD CONSTRAINT "document_issues_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "document_issues" ADD CONSTRAINT "document_issues_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "documents_company_idx" ON "documents" USING btree ("company_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "documents_project_idx" ON "documents" USING btree ("project_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "documents_issue_idx" ON "documents" USING btree ("issue_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_goals_document_idx" ON "document_goals" USING btree ("document_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_goals_goal_idx" ON "document_goals" USING btree ("goal_id");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "document_goals_unique" ON "document_goals" USING btree ("document_id","goal_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_issues_document_idx" ON "document_issues" USING btree ("document_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "document_issues_issue_idx" ON "document_issues" USING btree ("issue_id");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "document_issues_unique" ON "document_issues" USING btree ("document_id","issue_id");
7 changes: 7 additions & 0 deletions packages/db/src/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@
"when": 1773150731736,
"tag": "0027_tranquil_tenebrous",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1774410300000,
"tag": "0028_documents",
"breakpoints": true
}
]
}
22 changes: 22 additions & 0 deletions packages/db/src/schema/document_goals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {
pgTable,
uuid,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { documents } from "./documents.js";
import { goals } from "./goals.js";

export const documentGoals = pgTable(
"document_goals",
{
id: uuid("id").primaryKey().defaultRandom(),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
goalId: uuid("goal_id").notNull().references(() => goals.id, { onDelete: "cascade" }),
},
(table) => ({
documentIdx: index("document_goals_document_idx").on(table.documentId),
goalIdx: index("document_goals_goal_idx").on(table.goalId),
unique: uniqueIndex("document_goals_unique").on(table.documentId, table.goalId),
}),
);
22 changes: 22 additions & 0 deletions packages/db/src/schema/document_issues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {
pgTable,
uuid,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { documents } from "./documents.js";
import { issues } from "./issues.js";

export const documentIssues = pgTable(
"document_issues",
{
id: uuid("id").primaryKey().defaultRandom(),
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
},
(table) => ({
documentIdx: index("document_issues_document_idx").on(table.documentId),
issueIdx: index("document_issues_issue_idx").on(table.issueId),
unique: uniqueIndex("document_issues_unique").on(table.documentId, table.issueId),
}),
);
33 changes: 33 additions & 0 deletions packages/db/src/schema/documents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
index,
} from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { projects } from "./projects.js";

export const documents = pgTable(
"documents",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
projectId: uuid("project_id").references(() => projects.id),
issueId: uuid("issue_id").notNull().references(() => issues.id),
title: text("title").notNull(),
content: jsonb("content").default({}),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id),
createdByUserId: text("created_by_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIdx: index("documents_company_idx").on(table.companyId),
projectIdx: index("documents_project_idx").on(table.projectId),
issueIdx: index("documents_issue_idx").on(table.issueId),
}),
);
3 changes: 3 additions & 0 deletions packages/db/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ export { approvalComments } from "./approval_comments.js";
export { activityLog } from "./activity_log.js";
export { companySecrets } from "./company_secrets.js";
export { companySecretVersions } from "./company_secret_versions.js";
export { documents } from "./documents.js";
export { documentGoals } from "./document_goals.js";
export { documentIssues } from "./document_issues.js";
Loading