Add issue document revision restore flow

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-26 08:24:57 -05:00
parent 66aa65f8f7
commit b0b9809732
13 changed files with 12345 additions and 141 deletions

View file

@ -0,0 +1,173 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
const issueId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockDocumentsService = vi.hoisted(() => ({
listIssueDocumentRevisions: vi.fn(),
restoreIssueDocumentRevision: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
documentService: () => mockDocumentsService,
executionWorkspaceService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
describe("issue document revision routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.getById.mockResolvedValue({
id: issueId,
companyId,
identifier: "PAP-881",
title: "Document revisions",
status: "in_progress",
});
mockDocumentsService.listIssueDocumentRevisions.mockResolvedValue([
{
id: "revision-2",
companyId,
documentId: "document-1",
issueId,
key: "plan",
revisionNumber: 2,
title: "Plan v2",
format: "markdown",
body: "# Two",
changeSummary: null,
createdByAgentId: null,
createdByUserId: "board-user",
createdAt: new Date("2026-03-26T12:00:00.000Z"),
},
]);
mockDocumentsService.restoreIssueDocumentRevision.mockResolvedValue({
restoredFromRevisionId: "revision-1",
restoredFromRevisionNumber: 1,
document: {
id: "document-1",
companyId,
issueId,
key: "plan",
title: "Plan v1",
format: "markdown",
body: "# One",
latestRevisionId: "revision-3",
latestRevisionNumber: 3,
createdByAgentId: null,
createdByUserId: "board-user",
updatedByAgentId: null,
updatedByUserId: "board-user",
createdAt: new Date("2026-03-26T12:00:00.000Z"),
updatedAt: new Date("2026-03-26T12:10:00.000Z"),
},
});
});
it("returns revision snapshots including title and format", async () => {
const res = await request(createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`);
expect(res.status).toBe(200);
expect(mockDocumentsService.listIssueDocumentRevisions).toHaveBeenCalledWith(issueId, "plan");
expect(res.body).toEqual([
expect.objectContaining({
revisionNumber: 2,
title: "Plan v2",
format: "markdown",
body: "# Two",
}),
]);
});
it("restores a revision through the append-only route and logs the action", async () => {
const res = await request(createApp())
.post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`)
.send({});
expect(res.status).toBe(200);
expect(mockDocumentsService.restoreIssueDocumentRevision).toHaveBeenCalledWith({
issueId,
key: "plan",
revisionId: "revision-1",
createdByAgentId: null,
createdByUserId: "board-user",
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.document_restored",
details: expect.objectContaining({
key: "plan",
restoredFromRevisionId: "revision-1",
restoredFromRevisionNumber: 1,
revisionNumber: 3,
}),
}),
);
expect(res.body).toEqual(expect.objectContaining({
key: "plan",
title: "Plan v1",
latestRevisionNumber: 3,
}));
});
it("rejects invalid document keys before attempting restore", async () => {
const res = await request(createApp())
.post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`)
.send({});
expect(res.status).toBe(400);
expect(mockDocumentsService.restoreIssueDocumentRevision).not.toHaveBeenCalled();
});
});

View file

@ -10,6 +10,7 @@ import {
createIssueSchema,
linkIssueApprovalSchema,
issueDocumentKeySchema,
restoreIssueDocumentRevisionSchema,
updateIssueWorkProductSchema,
upsertIssueDocumentSchema,
updateIssueSchema,
@ -543,6 +544,57 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.json(revisions);
});
router.post(
"/issues/:id/documents/:key/revisions/:revisionId/restore",
validate(restoreIssueDocumentRevisionSchema),
async (req, res) => {
const id = req.params.id as string;
const revisionId = req.params.revisionId 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 result = await documentsSvc.restoreIssueDocumentRevision({
issueId: issue.id,
key: keyParsed.data,
revisionId,
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: "issue.document_restored",
entityType: "issue",
entityId: issue.id,
details: {
key: result.document.key,
documentId: result.document.id,
title: result.document.title,
format: result.document.format,
revisionNumber: result.document.latestRevisionNumber,
restoredFromRevisionId: result.restoredFromRevisionId,
restoredFromRevisionNumber: result.restoredFromRevisionNumber,
},
});
res.json(result.document);
},
);
router.delete("/issues/:id/documents/:key", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);

View file

@ -64,50 +64,36 @@ function mapIssueDocumentRow(
};
}
const issueDocumentSelect = {
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,
};
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,
})
.select(issueDocumentSelect)
.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,
})
.select(issueDocumentSelect)
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(eq(issueDocuments.issueId, issue.id))
@ -131,23 +117,7 @@ export function documentService(db: Db) {
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,
})
.select(issueDocumentSelect)
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(eq(issueDocuments.issueId, issueId))
@ -158,23 +128,7 @@ export function documentService(db: Db) {
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,
})
.select(issueDocumentSelect)
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
@ -192,6 +146,8 @@ export function documentService(db: Db) {
issueId: issueDocuments.issueId,
key: issueDocuments.key,
revisionNumber: documentRevisions.revisionNumber,
title: documentRevisions.title,
format: documentRevisions.format,
body: documentRevisions.body,
changeSummary: documentRevisions.changeSummary,
createdByAgentId: documentRevisions.createdByAgentId,
@ -269,6 +225,8 @@ export function documentService(db: Db) {
companyId: issue.companyId,
documentId: existing.id,
revisionNumber: nextRevisionNumber,
title: input.title ?? null,
format: input.format,
body: input.body,
changeSummary: input.changeSummary ?? null,
createdByAgentId: input.createdByAgentId ?? null,
@ -340,6 +298,8 @@ export function documentService(db: Db) {
companyId: issue.companyId,
documentId: document.id,
revisionNumber: 1,
title: input.title ?? null,
format: input.format,
body: input.body,
changeSummary: input.changeSummary ?? null,
createdByAgentId: input.createdByAgentId ?? null,
@ -391,27 +351,105 @@ export function documentService(db: Db) {
}
},
restoreIssueDocumentRevision: async (input: {
issueId: string;
key: string;
revisionId: string;
createdByAgentId?: string | null;
createdByUserId?: string | null;
}) => {
const key = normalizeDocumentKey(input.key);
return db.transaction(async (tx) => {
const existing = await tx
.select(issueDocumentSelect)
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, input.issueId), eq(issueDocuments.key, key)))
.then((rows) => rows[0] ?? null);
if (!existing) throw notFound("Document not found");
const revision = await tx
.select({
id: documentRevisions.id,
companyId: documentRevisions.companyId,
documentId: documentRevisions.documentId,
revisionNumber: documentRevisions.revisionNumber,
title: documentRevisions.title,
format: documentRevisions.format,
body: documentRevisions.body,
})
.from(documentRevisions)
.where(and(eq(documentRevisions.id, input.revisionId), eq(documentRevisions.documentId, existing.id)))
.then((rows) => rows[0] ?? null);
if (!revision) throw notFound("Document revision not found");
if (existing.latestRevisionId === revision.id) {
throw conflict("Selected revision is already the latest revision", {
currentRevisionId: existing.latestRevisionId,
});
}
const now = new Date();
const nextRevisionNumber = existing.latestRevisionNumber + 1;
const [restoredRevision] = await tx
.insert(documentRevisions)
.values({
companyId: existing.companyId,
documentId: existing.id,
revisionNumber: nextRevisionNumber,
title: revision.title ?? null,
format: revision.format,
body: revision.body,
changeSummary: `Restored from revision ${revision.revisionNumber}`,
createdByAgentId: input.createdByAgentId ?? null,
createdByUserId: input.createdByUserId ?? null,
createdAt: now,
})
.returning();
await tx
.update(documents)
.set({
title: revision.title ?? null,
format: revision.format,
latestBody: revision.body,
latestRevisionId: restoredRevision.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 {
restoredFromRevisionId: revision.id,
restoredFromRevisionNumber: revision.revisionNumber,
document: {
...existing,
title: revision.title ?? null,
format: revision.format,
body: revision.body,
latestRevisionId: restoredRevision.id,
latestRevisionNumber: nextRevisionNumber,
updatedByAgentId: input.createdByAgentId ?? null,
updatedByUserId: input.createdByUserId ?? null,
updatedAt: now,
},
};
});
},
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,
})
.select(issueDocumentSelect)
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))