feat(issues): add issue documents and inline editing

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-13 21:30:48 -05:00
parent 0f3e9937f6
commit 45998aa9a0
32 changed files with 9157 additions and 97 deletions

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { extractLegacyPlanBody } from "../services/documents.js";
describe("extractLegacyPlanBody", () => {
it("returns null when no plan block exists", () => {
expect(extractLegacyPlanBody("hello world")).toBeNull();
});
it("extracts plan body from legacy issue descriptions", () => {
expect(
extractLegacyPlanBody(`
intro
<plan>
# Plan
- one
- two
</plan>
`),
).toBe("# Plan\n\n- one\n- two");
});
it("ignores empty plan blocks", () => {
expect(extractLegacyPlanBody("<plan> </plan>")).toBeNull();
});
});

View File

@@ -21,6 +21,12 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
"image/jpg",
"image/webp",
"image/gif",
"application/pdf",
"text/markdown",
"text/plain",
"application/json",
"text/csv",
"text/html",
];
/**

View File

@@ -8,6 +8,8 @@ import {
checkoutIssueSchema,
createIssueSchema,
linkIssueApprovalSchema,
issueDocumentKeySchema,
upsertIssueDocumentSchema,
updateIssueSchema,
} from "@paperclipai/shared";
import type { StorageService } from "../storage/types.js";
@@ -19,6 +21,7 @@ import {
heartbeatService,
issueApprovalService,
issueService,
documentService,
logActivity,
projectService,
} from "../services/index.js";
@@ -37,6 +40,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
const projectsSvc = projectService(db);
const goalsSvc = goalService(db);
const issueApprovalsSvc = issueApprovalService(db);
const documentsSvc = documentService(db);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
@@ -291,7 +295,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
return;
}
assertCompanyAccess(req, issue.companyId);
const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([
const [ancestors, project, goal, mentionedProjectIds, documentPayload] = await Promise.all([
svc.getAncestors(issue.id),
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
issue.goalId
@@ -300,6 +304,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
: null,
svc.findMentionedProjectIds(issue.id),
documentsSvc.getIssueDocumentPayload(issue),
]);
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
@@ -308,12 +313,153 @@ export function issueRoutes(db: Db, storage: StorageService) {
...issue,
goalId: goal?.id ?? issue.goalId,
ancestors,
...documentPayload,
project: project ?? null,
goal: goal ?? null,
mentionedProjects,
});
});
router.get("/issues/:id/documents", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const docs = await documentsSvc.listIssueDocuments(issue.id);
res.json(docs);
});
router.get("/issues/:id/documents/:key", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const doc = await documentsSvc.getIssueDocumentByKey(issue.id, keyParsed.data);
if (!doc) {
res.status(404).json({ error: "Document not found" });
return;
}
res.json(doc);
});
router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const actor = getActorInfo(req);
const before = await documentsSvc.getIssueDocumentByKey(issue.id, keyParsed.data);
const doc = await documentsSvc.upsertIssueDocument({
issueId: issue.id,
key: keyParsed.data,
title: req.body.title ?? null,
format: req.body.format,
body: req.body.body,
changeSummary: req.body.changeSummary ?? null,
baseRevisionId: req.body.baseRevisionId ?? null,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: before ? "issue.document_updated" : "issue.document_created",
entityType: "issue",
entityId: issue.id,
details: {
key: doc.key,
documentId: doc.id,
title: doc.title,
format: doc.format,
revisionNumber: doc.latestRevisionNumber,
},
});
res.status(before ? 200 : 201).json(doc);
});
router.get("/issues/:id/documents/:key/revisions", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const revisions = await documentsSvc.listIssueDocumentRevisions(issue.id, keyParsed.data);
res.json(revisions);
});
router.delete("/issues/:id/documents/:key", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
return;
}
const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data);
if (!removed) {
res.status(404).json({ error: "Document not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.document_deleted",
entityType: "issue",
entityId: issue.id,
details: {
key: removed.key,
documentId: removed.id,
title: removed.title,
},
});
res.json({ ok: true });
});
router.post("/issues/:id/read", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);

View File

@@ -0,0 +1,427 @@
import { and, asc, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db";
import { issueDocumentKeySchema } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
function normalizeDocumentKey(key: string) {
const normalized = key.trim().toLowerCase();
const parsed = issueDocumentKeySchema.safeParse(normalized);
if (!parsed.success) {
throw unprocessable("Invalid document key", parsed.error.issues);
}
return parsed.data;
}
function isUniqueViolation(error: unknown): boolean {
return !!error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "23505";
}
export function extractLegacyPlanBody(description: string | null | undefined) {
if (!description) return null;
const match = /<plan>\s*([\s\S]*?)\s*<\/plan>/i.exec(description);
if (!match) return null;
const body = match[1]?.trim();
return body ? body : null;
}
function mapIssueDocumentRow(
row: {
id: string;
companyId: string;
issueId: string;
key: string;
title: string | null;
format: string;
latestBody: string;
latestRevisionId: string | null;
latestRevisionNumber: number;
createdByAgentId: string | null;
createdByUserId: string | null;
updatedByAgentId: string | null;
updatedByUserId: string | null;
createdAt: Date;
updatedAt: Date;
},
includeBody: boolean,
) {
return {
id: row.id,
companyId: row.companyId,
issueId: row.issueId,
key: row.key,
title: row.title,
format: row.format,
...(includeBody ? { body: row.latestBody } : {}),
latestRevisionId: row.latestRevisionId ?? "",
latestRevisionNumber: row.latestRevisionNumber,
createdByAgentId: row.createdByAgentId,
createdByUserId: row.createdByUserId,
updatedByAgentId: row.updatedByAgentId,
updatedByUserId: row.updatedByUserId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function documentService(db: Db) {
return {
getIssueDocumentPayload: async (issue: { id: string; description: string | null }) => {
const [planDocument, documentSummaries] = await Promise.all([
db
.select({
id: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
createdByAgentId: documents.createdByAgentId,
createdByUserId: documents.createdByUserId,
updatedByAgentId: documents.updatedByAgentId,
updatedByUserId: documents.updatedByUserId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, "plan")))
.then((rows) => rows[0] ?? null),
db
.select({
id: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
createdByAgentId: documents.createdByAgentId,
createdByUserId: documents.createdByUserId,
updatedByAgentId: documents.updatedByAgentId,
updatedByUserId: documents.updatedByUserId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(eq(issueDocuments.issueId, issue.id))
.orderBy(asc(issueDocuments.key), desc(documents.updatedAt)),
]);
const legacyPlanBody = planDocument ? null : extractLegacyPlanBody(issue.description);
return {
planDocument: planDocument ? mapIssueDocumentRow(planDocument, true) : null,
documentSummaries: documentSummaries.map((row) => mapIssueDocumentRow(row, false)),
legacyPlanDocument: legacyPlanBody
? {
key: "plan" as const,
body: legacyPlanBody,
source: "issue_description" as const,
}
: null,
};
},
listIssueDocuments: async (issueId: string) => {
const rows = await db
.select({
id: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
createdByAgentId: documents.createdByAgentId,
createdByUserId: documents.createdByUserId,
updatedByAgentId: documents.updatedByAgentId,
updatedByUserId: documents.updatedByUserId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(eq(issueDocuments.issueId, issueId))
.orderBy(asc(issueDocuments.key), desc(documents.updatedAt));
return rows.map((row) => mapIssueDocumentRow(row, true));
},
getIssueDocumentByKey: async (issueId: string, rawKey: string) => {
const key = normalizeDocumentKey(rawKey);
const row = await db
.select({
id: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
createdByAgentId: documents.createdByAgentId,
createdByUserId: documents.createdByUserId,
updatedByAgentId: documents.updatedByAgentId,
updatedByUserId: documents.updatedByUserId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
.then((rows) => rows[0] ?? null);
return row ? mapIssueDocumentRow(row, true) : null;
},
listIssueDocumentRevisions: async (issueId: string, rawKey: string) => {
const key = normalizeDocumentKey(rawKey);
return db
.select({
id: documentRevisions.id,
companyId: documentRevisions.companyId,
documentId: documentRevisions.documentId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
revisionNumber: documentRevisions.revisionNumber,
body: documentRevisions.body,
changeSummary: documentRevisions.changeSummary,
createdByAgentId: documentRevisions.createdByAgentId,
createdByUserId: documentRevisions.createdByUserId,
createdAt: documentRevisions.createdAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.innerJoin(documentRevisions, eq(documentRevisions.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
.orderBy(desc(documentRevisions.revisionNumber));
},
upsertIssueDocument: async (input: {
issueId: string;
key: string;
title?: string | null;
format: string;
body: string;
changeSummary?: string | null;
baseRevisionId?: string | null;
createdByAgentId?: string | null;
createdByUserId?: string | null;
}) => {
const key = normalizeDocumentKey(input.key);
const issue = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, input.issueId))
.then((rows) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
try {
return await db.transaction(async (tx) => {
const now = new Date();
const existing = await tx
.select({
id: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
createdByAgentId: documents.createdByAgentId,
createdByUserId: documents.createdByUserId,
updatedByAgentId: documents.updatedByAgentId,
updatedByUserId: documents.updatedByUserId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, key)))
.then((rows) => rows[0] ?? null);
if (existing) {
if (!input.baseRevisionId) {
throw conflict("Document update requires baseRevisionId", {
currentRevisionId: existing.latestRevisionId,
});
}
if (input.baseRevisionId !== existing.latestRevisionId) {
throw conflict("Document was updated by someone else", {
currentRevisionId: existing.latestRevisionId,
});
}
const nextRevisionNumber = existing.latestRevisionNumber + 1;
const [revision] = await tx
.insert(documentRevisions)
.values({
companyId: issue.companyId,
documentId: existing.id,
revisionNumber: nextRevisionNumber,
body: input.body,
changeSummary: input.changeSummary ?? null,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
createdAt: now,
})
.returning();
await tx
.update(documents)
.set({
title: input.title ?? null,
format: input.format,
latestBody: input.body,
latestRevisionId: revision.id,
latestRevisionNumber: nextRevisionNumber,
updatedByAgentId: input.createdByAgentId ?? null,
updatedByUserId: input.createdByUserId ?? null,
updatedAt: now,
})
.where(eq(documents.id, existing.id));
await tx
.update(issueDocuments)
.set({ updatedAt: now })
.where(eq(issueDocuments.documentId, existing.id));
return {
...existing,
title: input.title ?? null,
format: input.format,
body: input.body,
latestRevisionId: revision.id,
latestRevisionNumber: nextRevisionNumber,
updatedByAgentId: input.createdByAgentId ?? null,
updatedByUserId: input.createdByUserId ?? null,
updatedAt: now,
};
}
if (input.baseRevisionId) {
throw conflict("Document does not exist yet", { key });
}
const [document] = await tx
.insert(documents)
.values({
companyId: issue.companyId,
title: input.title ?? null,
format: input.format,
latestBody: input.body,
latestRevisionId: null,
latestRevisionNumber: 1,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
updatedByAgentId: input.createdByAgentId ?? null,
updatedByUserId: input.createdByUserId ?? null,
createdAt: now,
updatedAt: now,
})
.returning();
const [revision] = await tx
.insert(documentRevisions)
.values({
companyId: issue.companyId,
documentId: document.id,
revisionNumber: 1,
body: input.body,
changeSummary: input.changeSummary ?? null,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
createdAt: now,
})
.returning();
await tx
.update(documents)
.set({ latestRevisionId: revision.id })
.where(eq(documents.id, document.id));
await tx.insert(issueDocuments).values({
companyId: issue.companyId,
issueId: issue.id,
documentId: document.id,
key,
createdAt: now,
updatedAt: now,
});
return {
id: document.id,
companyId: issue.companyId,
issueId: issue.id,
key,
title: document.title,
format: document.format,
body: document.latestBody,
latestRevisionId: revision.id,
latestRevisionNumber: 1,
createdByAgentId: document.createdByAgentId,
createdByUserId: document.createdByUserId,
updatedByAgentId: document.updatedByAgentId,
updatedByUserId: document.updatedByUserId,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
};
});
} catch (error) {
if (isUniqueViolation(error)) {
throw conflict("Document key already exists on this issue", { key });
}
throw error;
}
},
deleteIssueDocument: async (issueId: string, rawKey: string) => {
const key = normalizeDocumentKey(rawKey);
return db.transaction(async (tx) => {
const existing = await tx
.select({
id: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
title: documents.title,
format: documents.format,
latestBody: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
createdByAgentId: documents.createdByAgentId,
createdByUserId: documents.createdByUserId,
updatedByAgentId: documents.updatedByAgentId,
updatedByUserId: documents.updatedByUserId,
createdAt: documents.createdAt,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
await tx.delete(issueDocuments).where(eq(issueDocuments.documentId, existing.id));
await tx.delete(documents).where(eq(documents.id, existing.id));
return {
...existing,
body: existing.latestBody,
latestRevisionId: existing.latestRevisionId ?? "",
};
});
},
};
}

View File

@@ -1,6 +1,7 @@
export { companyService } from "./companies.js";
export { agentService, deduplicateAgentName } from "./agents.js";
export { assetService } from "./assets.js";
export { documentService, extractLegacyPlanBody } from "./documents.js";
export { projectService } from "./projects.js";
export { issueService, type IssueFilters } from "./issues.js";
export { issueApprovalService } from "./issue-approvals.js";