mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
Add issue document revision restore flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
66aa65f8f7
commit
b0b9809732
13 changed files with 12345 additions and 141 deletions
173
server/src/__tests__/issue-document-restore-routes.test.ts
Normal file
173
server/src/__tests__/issue-document-restore-routes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue