Merge pull request #2317 from paperclipai/PAP-881-document-revisions-bulid-it
Add issue document revision restore flow
This commit is contained in:
commit
46ce546174
15 changed files with 12577 additions and 142 deletions
|
|
@ -415,7 +415,7 @@ describe("worktree helpers", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8"));
|
const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8"));
|
||||||
expect(config.server.port).toBe(3102);
|
expect(config.server.port).toBeGreaterThan(3101);
|
||||||
expect(config.database.embeddedPostgresPort).not.toBe(54330);
|
expect(config.database.embeddedPostgresPort).not.toBe(54330);
|
||||||
expect(config.database.embeddedPostgresPort).not.toBe(config.server.port);
|
expect(config.database.embeddedPostgresPort).not.toBe(config.server.port);
|
||||||
expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330);
|
expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330);
|
||||||
|
|
|
||||||
|
|
@ -169,4 +169,76 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
|
||||||
},
|
},
|
||||||
20_000,
|
20_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"replays migration 0046 safely when document revision columns already exist",
|
||||||
|
async () => {
|
||||||
|
const connectionString = await createTempDatabase();
|
||||||
|
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
|
||||||
|
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||||
|
try {
|
||||||
|
const smoothSentinelsHash = await migrationHash("0046_smooth_sentinels.sql");
|
||||||
|
|
||||||
|
await sql.unsafe(
|
||||||
|
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${smoothSentinelsHash}'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = await sql.unsafe<{ column_name: string; is_nullable: string; column_default: string | null }[]>(
|
||||||
|
`
|
||||||
|
SELECT column_name, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'document_revisions'
|
||||||
|
AND column_name IN ('title', 'format')
|
||||||
|
ORDER BY column_name
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
expect(columns).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingState = await inspectMigrations(connectionString);
|
||||||
|
expect(pendingState).toMatchObject({
|
||||||
|
status: "needsMigrations",
|
||||||
|
pendingMigrations: ["0046_smooth_sentinels.sql"],
|
||||||
|
reason: "pending-migrations",
|
||||||
|
});
|
||||||
|
|
||||||
|
await applyPendingMigrations(connectionString);
|
||||||
|
|
||||||
|
const finalState = await inspectMigrations(connectionString);
|
||||||
|
expect(finalState.status).toBe("upToDate");
|
||||||
|
|
||||||
|
const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||||
|
try {
|
||||||
|
const columns = await verifySql.unsafe<{ column_name: string; is_nullable: string; column_default: string | null }[]>(
|
||||||
|
`
|
||||||
|
SELECT column_name, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'document_revisions'
|
||||||
|
AND column_name IN ('title', 'format')
|
||||||
|
ORDER BY column_name
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
expect(columns).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
column_name: "format",
|
||||||
|
is_nullable: "NO",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
column_name: "title",
|
||||||
|
is_nullable: "YES",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(columns[0]?.column_default).toContain("'markdown'");
|
||||||
|
} finally {
|
||||||
|
await verifySql.end();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
11
packages/db/src/migrations/0046_smooth_sentinels.sql
Normal file
11
packages/db/src/migrations/0046_smooth_sentinels.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
ALTER TABLE "document_revisions" ADD COLUMN IF NOT EXISTS "title" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_revisions" ADD COLUMN IF NOT EXISTS "format" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_revisions" ALTER COLUMN "format" SET DEFAULT 'markdown';
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE "document_revisions" AS "dr"
|
||||||
|
SET
|
||||||
|
"title" = COALESCE("dr"."title", "d"."title"),
|
||||||
|
"format" = COALESCE("dr"."format", "d"."format", 'markdown')
|
||||||
|
FROM "documents" AS "d"
|
||||||
|
WHERE "d"."id" = "dr"."document_id";--> statement-breakpoint
|
||||||
|
ALTER TABLE "document_revisions" ALTER COLUMN "format" SET NOT NULL;
|
||||||
11870
packages/db/src/migrations/meta/0046_snapshot.json
Normal file
11870
packages/db/src/migrations/meta/0046_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -323,6 +323,13 @@
|
||||||
"when": 1774530504348,
|
"when": 1774530504348,
|
||||||
"tag": "0045_workable_shockwave",
|
"tag": "0045_workable_shockwave",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 46,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774960197878,
|
||||||
|
"tag": "0046_smooth_sentinels",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ export const documentRevisions = pgTable(
|
||||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||||
revisionNumber: integer("revision_number").notNull(),
|
revisionNumber: integer("revision_number").notNull(),
|
||||||
|
title: text("title"),
|
||||||
|
format: text("format").notNull().default("markdown"),
|
||||||
body: text("body").notNull(),
|
body: text("body").notNull(),
|
||||||
changeSummary: text("change_summary"),
|
changeSummary: text("change_summary"),
|
||||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
|
|
|
||||||
|
|
@ -406,6 +406,7 @@ export {
|
||||||
issueDocumentFormatSchema,
|
issueDocumentFormatSchema,
|
||||||
issueDocumentKeySchema,
|
issueDocumentKeySchema,
|
||||||
upsertIssueDocumentSchema,
|
upsertIssueDocumentSchema,
|
||||||
|
restoreIssueDocumentRevisionSchema,
|
||||||
type CreateIssue,
|
type CreateIssue,
|
||||||
type CreateIssueLabel,
|
type CreateIssueLabel,
|
||||||
type UpdateIssue,
|
type UpdateIssue,
|
||||||
|
|
@ -418,6 +419,7 @@ export {
|
||||||
type UpdateExecutionWorkspace,
|
type UpdateExecutionWorkspace,
|
||||||
type IssueDocumentFormat,
|
type IssueDocumentFormat,
|
||||||
type UpsertIssueDocument,
|
type UpsertIssueDocument,
|
||||||
|
type RestoreIssueDocumentRevision,
|
||||||
createGoalSchema,
|
createGoalSchema,
|
||||||
updateGoalSchema,
|
updateGoalSchema,
|
||||||
type CreateGoal,
|
type CreateGoal,
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,8 @@ export interface DocumentRevision {
|
||||||
issueId: string;
|
issueId: string;
|
||||||
key: string;
|
key: string;
|
||||||
revisionNumber: number;
|
revisionNumber: number;
|
||||||
|
title: string | null;
|
||||||
|
format: DocumentFormat;
|
||||||
body: string;
|
body: string;
|
||||||
changeSummary: string | null;
|
changeSummary: string | null;
|
||||||
createdByAgentId: string | null;
|
createdByAgentId: string | null;
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ export {
|
||||||
issueDocumentFormatSchema,
|
issueDocumentFormatSchema,
|
||||||
issueDocumentKeySchema,
|
issueDocumentKeySchema,
|
||||||
upsertIssueDocumentSchema,
|
upsertIssueDocumentSchema,
|
||||||
|
restoreIssueDocumentRevisionSchema,
|
||||||
type CreateIssue,
|
type CreateIssue,
|
||||||
type CreateIssueLabel,
|
type CreateIssueLabel,
|
||||||
type UpdateIssue,
|
type UpdateIssue,
|
||||||
|
|
@ -141,6 +142,7 @@ export {
|
||||||
type CreateIssueAttachmentMetadata,
|
type CreateIssueAttachmentMetadata,
|
||||||
type IssueDocumentFormat,
|
type IssueDocumentFormat,
|
||||||
type UpsertIssueDocument,
|
type UpsertIssueDocument,
|
||||||
|
type RestoreIssueDocumentRevision,
|
||||||
} from "./issue.js";
|
} from "./issue.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
|
|
@ -120,5 +120,8 @@ export const upsertIssueDocumentSchema = z.object({
|
||||||
baseRevisionId: z.string().uuid().nullable().optional(),
|
baseRevisionId: z.string().uuid().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const restoreIssueDocumentRevisionSchema = z.object({});
|
||||||
|
|
||||||
export type IssueDocumentFormat = z.infer<typeof issueDocumentFormatSchema>;
|
export type IssueDocumentFormat = z.infer<typeof issueDocumentFormatSchema>;
|
||||||
export type UpsertIssueDocument = z.infer<typeof upsertIssueDocumentSchema>;
|
export type UpsertIssueDocument = z.infer<typeof upsertIssueDocumentSchema>;
|
||||||
|
export type RestoreIssueDocumentRevision = z.infer<typeof restoreIssueDocumentRevisionSchema>;
|
||||||
|
|
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
createIssueSchema,
|
createIssueSchema,
|
||||||
linkIssueApprovalSchema,
|
linkIssueApprovalSchema,
|
||||||
issueDocumentKeySchema,
|
issueDocumentKeySchema,
|
||||||
|
restoreIssueDocumentRevisionSchema,
|
||||||
updateIssueWorkProductSchema,
|
updateIssueWorkProductSchema,
|
||||||
upsertIssueDocumentSchema,
|
upsertIssueDocumentSchema,
|
||||||
updateIssueSchema,
|
updateIssueSchema,
|
||||||
|
|
@ -582,6 +583,57 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
res.json(revisions);
|
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) => {
|
router.delete("/issues/:id/documents/:key", async (req, res) => {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const issue = await svc.getById(id);
|
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) {
|
export function documentService(db: Db) {
|
||||||
return {
|
return {
|
||||||
getIssueDocumentPayload: async (issue: { id: string; description: string | null }) => {
|
getIssueDocumentPayload: async (issue: { id: string; description: string | null }) => {
|
||||||
const [planDocument, documentSummaries] = await Promise.all([
|
const [planDocument, documentSummaries] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select(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,
|
|
||||||
})
|
|
||||||
.from(issueDocuments)
|
.from(issueDocuments)
|
||||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||||
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, "plan")))
|
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, "plan")))
|
||||||
.then((rows) => rows[0] ?? null),
|
.then((rows) => rows[0] ?? null),
|
||||||
db
|
db
|
||||||
.select({
|
.select(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,
|
|
||||||
})
|
|
||||||
.from(issueDocuments)
|
.from(issueDocuments)
|
||||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||||
.where(eq(issueDocuments.issueId, issue.id))
|
.where(eq(issueDocuments.issueId, issue.id))
|
||||||
|
|
@ -131,23 +117,7 @@ export function documentService(db: Db) {
|
||||||
|
|
||||||
listIssueDocuments: async (issueId: string) => {
|
listIssueDocuments: async (issueId: string) => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select(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,
|
|
||||||
})
|
|
||||||
.from(issueDocuments)
|
.from(issueDocuments)
|
||||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||||
.where(eq(issueDocuments.issueId, issueId))
|
.where(eq(issueDocuments.issueId, issueId))
|
||||||
|
|
@ -158,23 +128,7 @@ export function documentService(db: Db) {
|
||||||
getIssueDocumentByKey: async (issueId: string, rawKey: string) => {
|
getIssueDocumentByKey: async (issueId: string, rawKey: string) => {
|
||||||
const key = normalizeDocumentKey(rawKey);
|
const key = normalizeDocumentKey(rawKey);
|
||||||
const row = await db
|
const row = await db
|
||||||
.select({
|
.select(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,
|
|
||||||
})
|
|
||||||
.from(issueDocuments)
|
.from(issueDocuments)
|
||||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||||
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
||||||
|
|
@ -192,6 +146,8 @@ export function documentService(db: Db) {
|
||||||
issueId: issueDocuments.issueId,
|
issueId: issueDocuments.issueId,
|
||||||
key: issueDocuments.key,
|
key: issueDocuments.key,
|
||||||
revisionNumber: documentRevisions.revisionNumber,
|
revisionNumber: documentRevisions.revisionNumber,
|
||||||
|
title: documentRevisions.title,
|
||||||
|
format: documentRevisions.format,
|
||||||
body: documentRevisions.body,
|
body: documentRevisions.body,
|
||||||
changeSummary: documentRevisions.changeSummary,
|
changeSummary: documentRevisions.changeSummary,
|
||||||
createdByAgentId: documentRevisions.createdByAgentId,
|
createdByAgentId: documentRevisions.createdByAgentId,
|
||||||
|
|
@ -269,6 +225,8 @@ export function documentService(db: Db) {
|
||||||
companyId: issue.companyId,
|
companyId: issue.companyId,
|
||||||
documentId: existing.id,
|
documentId: existing.id,
|
||||||
revisionNumber: nextRevisionNumber,
|
revisionNumber: nextRevisionNumber,
|
||||||
|
title: input.title ?? null,
|
||||||
|
format: input.format,
|
||||||
body: input.body,
|
body: input.body,
|
||||||
changeSummary: input.changeSummary ?? null,
|
changeSummary: input.changeSummary ?? null,
|
||||||
createdByAgentId: input.createdByAgentId ?? null,
|
createdByAgentId: input.createdByAgentId ?? null,
|
||||||
|
|
@ -340,6 +298,8 @@ export function documentService(db: Db) {
|
||||||
companyId: issue.companyId,
|
companyId: issue.companyId,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
revisionNumber: 1,
|
revisionNumber: 1,
|
||||||
|
title: input.title ?? null,
|
||||||
|
format: input.format,
|
||||||
body: input.body,
|
body: input.body,
|
||||||
changeSummary: input.changeSummary ?? null,
|
changeSummary: input.changeSummary ?? null,
|
||||||
createdByAgentId: input.createdByAgentId ?? 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) => {
|
deleteIssueDocument: async (issueId: string, rawKey: string) => {
|
||||||
const key = normalizeDocumentKey(rawKey);
|
const key = normalizeDocumentKey(rawKey);
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
const existing = await tx
|
const existing = await tx
|
||||||
.select({
|
.select(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,
|
|
||||||
})
|
|
||||||
.from(issueDocuments)
|
.from(issueDocuments)
|
||||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||||
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ export const issuesApi = {
|
||||||
api.put<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`, data),
|
api.put<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}`, data),
|
||||||
listDocumentRevisions: (id: string, key: string) =>
|
listDocumentRevisions: (id: string, key: string) =>
|
||||||
api.get<DocumentRevision[]>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`),
|
api.get<DocumentRevision[]>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions`),
|
||||||
|
restoreDocumentRevision: (id: string, key: string, revisionId: string) =>
|
||||||
|
api.post<IssueDocument>(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions/${revisionId}/restore`, {}),
|
||||||
deleteDocument: (id: string, key: string) =>
|
deleteDocument: (id: string, key: string) =>
|
||||||
api.delete<{ ok: true }>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
api.delete<{ ok: true }>(`/issues/${id}/documents/${encodeURIComponent(key)}`),
|
||||||
listAttachments: (id: string) => api.get<IssueAttachment[]>(`/issues/${id}/attachments`),
|
listAttachments: (id: string) => api.get<IssueAttachment[]>(`/issues/${id}/attachments`),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Issue, IssueDocument } from "@paperclipai/shared";
|
import type { DocumentRevision, Issue, IssueDocument } from "@paperclipai/shared";
|
||||||
import { useLocation } from "@/lib/router";
|
import { useLocation } from "@/lib/router";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
|
|
@ -15,6 +15,9 @@ import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
@ -84,6 +87,17 @@ function downloadDocumentFile(key: string, body: string) {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRevisionActorLabel(revision: DocumentRevision) {
|
||||||
|
if (revision.createdByUserId) return "board";
|
||||||
|
if (revision.createdByAgentId) return "agent";
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentHasUnsavedChanges(doc: IssueDocument, draft: DraftState | null) {
|
||||||
|
if (!draft || draft.isNew || draft.key !== doc.key) return false;
|
||||||
|
return draft.body !== doc.body || (doc.title ?? "") !== draft.title;
|
||||||
|
}
|
||||||
|
|
||||||
export function IssueDocumentsSection({
|
export function IssueDocumentsSection({
|
||||||
issue,
|
issue,
|
||||||
canDeleteDocuments,
|
canDeleteDocuments,
|
||||||
|
|
@ -107,6 +121,8 @@ export function IssueDocumentsSection({
|
||||||
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
||||||
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
|
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
|
||||||
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||||
|
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
|
||||||
|
const [selectedRevisionIds, setSelectedRevisionIds] = useState<Record<string, string | null>>({});
|
||||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const hasScrolledToHashRef = useRef(false);
|
const hasScrolledToHashRef = useRef(false);
|
||||||
|
|
@ -122,10 +138,28 @@ export function IssueDocumentsSection({
|
||||||
queryFn: () => issuesApi.listDocuments(issue.id),
|
queryFn: () => issuesApi.listDocuments(issue.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const invalidateIssueDocuments = () => {
|
const { data: activeDocumentRevisions, isFetching: isFetchingDocumentRevisions } = useQuery({
|
||||||
|
queryKey: revisionMenuOpenKey
|
||||||
|
? queryKeys.issues.documentRevisions(issue.id, revisionMenuOpenKey)
|
||||||
|
: ["issues", "document-revisions", issue.id, "__idle__"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!revisionMenuOpenKey) return [];
|
||||||
|
return issuesApi.listDocumentRevisions(issue.id, revisionMenuOpenKey);
|
||||||
|
},
|
||||||
|
enabled: Boolean(revisionMenuOpenKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidateIssueDocuments = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
|
||||||
};
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) =>
|
||||||
|
Array.isArray(query.queryKey)
|
||||||
|
&& query.queryKey[0] === "issues"
|
||||||
|
&& query.queryKey[1] === "document-revisions"
|
||||||
|
&& query.queryKey[2] === issue.id,
|
||||||
|
});
|
||||||
|
}, [issue.id, queryClient]);
|
||||||
|
|
||||||
const upsertDocument = useMutation({
|
const upsertDocument = useMutation({
|
||||||
mutationFn: async (nextDraft: DraftState) =>
|
mutationFn: async (nextDraft: DraftState) =>
|
||||||
|
|
@ -149,6 +183,22 @@ export function IssueDocumentsSection({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const restoreDocumentRevision = useMutation({
|
||||||
|
mutationFn: ({ key, revisionId }: { key: string; revisionId: string }) =>
|
||||||
|
issuesApi.restoreDocumentRevision(issue.id, key, revisionId),
|
||||||
|
onSuccess: (_document, variables) => {
|
||||||
|
setSelectedRevisionIds((current) => ({ ...current, [variables.key]: null }));
|
||||||
|
setDraft((current) => current?.key === variables.key ? null : current);
|
||||||
|
setDocumentConflict((current) => current?.key === variables.key ? null : current);
|
||||||
|
resetAutosaveState();
|
||||||
|
setError(null);
|
||||||
|
invalidateIssueDocuments();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to restore document revision");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const sortedDocuments = useMemo(() => {
|
const sortedDocuments = useMemo(() => {
|
||||||
return [...(documents ?? [])].sort((a, b) => {
|
return [...(documents ?? [])].sort((a, b) => {
|
||||||
if (a.key === "plan" && b.key !== "plan") return -1;
|
if (a.key === "plan" && b.key !== "plan") return -1;
|
||||||
|
|
@ -391,6 +441,38 @@ export function IssueDocumentsSection({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const getDocumentRevisions = useCallback((key: string) => {
|
||||||
|
const cached = queryClient.getQueryData<DocumentRevision[]>(queryKeys.issues.documentRevisions(issue.id, key));
|
||||||
|
if (cached) return cached;
|
||||||
|
if (revisionMenuOpenKey === key) return activeDocumentRevisions ?? [];
|
||||||
|
return [];
|
||||||
|
}, [activeDocumentRevisions, issue.id, queryClient, revisionMenuOpenKey]);
|
||||||
|
|
||||||
|
const returnToLatestRevision = useCallback((key: string) => {
|
||||||
|
setSelectedRevisionIds((current) => ({ ...current, [key]: null }));
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const previewRevision = useCallback((doc: IssueDocument, revisionId: string) => {
|
||||||
|
const revisions = getDocumentRevisions(doc.key);
|
||||||
|
const selectedRevision = revisions.find((revision) => revision.id === revisionId);
|
||||||
|
if (!selectedRevision) return;
|
||||||
|
if (selectedRevision.id === doc.latestRevisionId) {
|
||||||
|
returnToLatestRevision(doc.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (documentConflict?.key === doc.key || documentHasUnsavedChanges(doc, draft)) {
|
||||||
|
setError("Save or cancel your local changes before viewing an older revision.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetAutosaveState();
|
||||||
|
setDraft((current) => current?.key === doc.key ? null : current);
|
||||||
|
setDocumentConflict((current) => current?.key === doc.key ? null : current);
|
||||||
|
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== doc.key));
|
||||||
|
setSelectedRevisionIds((current) => ({ ...current, [doc.key]: selectedRevision.id }));
|
||||||
|
setError(null);
|
||||||
|
}, [documentConflict, draft, getDocumentRevisions, resetAutosaveState, returnToLatestRevision]);
|
||||||
|
|
||||||
const handleDraftBlur = async (event: React.FocusEvent<HTMLDivElement>) => {
|
const handleDraftBlur = async (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||||
if (autosaveDebounceRef.current) {
|
if (autosaveDebounceRef.current) {
|
||||||
|
|
@ -623,7 +705,19 @@ export function IssueDocumentsSection({
|
||||||
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
|
const activeDraft = draft?.key === doc.key && !draft.isNew ? draft : null;
|
||||||
const activeConflict = documentConflict?.key === doc.key ? documentConflict : null;
|
const activeConflict = documentConflict?.key === doc.key ? documentConflict : null;
|
||||||
const isFolded = foldedDocumentKeys.includes(doc.key);
|
const isFolded = foldedDocumentKeys.includes(doc.key);
|
||||||
const showTitle = !isPlanKey(doc.key) && !!doc.title?.trim() && !titlesMatchKey(doc.title, doc.key);
|
const revisionHistory = getDocumentRevisions(doc.key);
|
||||||
|
const selectedRevisionId = selectedRevisionIds[doc.key] ?? null;
|
||||||
|
const selectedHistoricalRevision = selectedRevisionId
|
||||||
|
? revisionHistory.find((revision) => revision.id === selectedRevisionId) ?? null
|
||||||
|
: null;
|
||||||
|
const isHistoricalPreview = Boolean(selectedHistoricalRevision);
|
||||||
|
const displayedTitle = selectedHistoricalRevision
|
||||||
|
? selectedHistoricalRevision.title ?? ""
|
||||||
|
: activeDraft?.title ?? doc.title ?? "";
|
||||||
|
const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? doc.body;
|
||||||
|
const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? doc.latestRevisionNumber;
|
||||||
|
const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? doc.updatedAt;
|
||||||
|
const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -649,14 +743,68 @@ export function IssueDocumentsSection({
|
||||||
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
{doc.key}
|
{doc.key}
|
||||||
</span>
|
</span>
|
||||||
|
<DropdownMenu
|
||||||
|
open={revisionMenuOpenKey === doc.key}
|
||||||
|
onOpenChange={(open) => setRevisionMenuOpenKey(open ? doc.key : null)}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-auto px-1.5 py-0 text-[11px] font-normal text-muted-foreground hover:text-foreground",
|
||||||
|
isHistoricalPreview && "text-amber-300 hover:text-amber-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
rev {displayedRevisionNumber}
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-72">
|
||||||
|
<DropdownMenuLabel>Revision history</DropdownMenuLabel>
|
||||||
|
{revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions && revisionHistory.length === 0 ? (
|
||||||
|
<DropdownMenuItem disabled>Loading revisions...</DropdownMenuItem>
|
||||||
|
) : revisionHistory.length > 0 ? (
|
||||||
|
<DropdownMenuRadioGroup value={selectedRevisionId ?? doc.latestRevisionId ?? ""}>
|
||||||
|
{revisionHistory.map((revision) => {
|
||||||
|
const isCurrentRevision = revision.id === doc.latestRevisionId;
|
||||||
|
return (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
key={revision.id}
|
||||||
|
value={revision.id}
|
||||||
|
onSelect={() => previewRevision(doc, revision.id)}
|
||||||
|
className="items-start"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">rev {revision.revisionNumber}</span>
|
||||||
|
{isCurrentRevision ? (
|
||||||
|
<span className="rounded-full border border-border px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{relativeTime(revision.createdAt)} • {getRevisionActorLabel(revision)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem disabled>No revisions yet</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<a
|
<a
|
||||||
href={`#document-${encodeURIComponent(doc.key)}`}
|
href={`#document-${encodeURIComponent(doc.key)}`}
|
||||||
className="truncate text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
className="truncate text-[11px] text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
rev {doc.latestRevisionNumber} • updated {relativeTime(doc.updatedAt)}
|
updated {relativeTime(displayedUpdatedAt)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{showTitle && <p className="mt-2 text-sm font-medium">{doc.title}</p>}
|
{showTitle && <p className="mt-2 text-sm font-medium">{displayedTitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -667,7 +815,7 @@ export function IssueDocumentsSection({
|
||||||
copiedDocumentKey === doc.key && "text-foreground",
|
copiedDocumentKey === doc.key && "text-foreground",
|
||||||
)}
|
)}
|
||||||
title={copiedDocumentKey === doc.key ? "Copied" : "Copy document"}
|
title={copiedDocumentKey === doc.key ? "Copied" : "Copy document"}
|
||||||
onClick={() => void copyDocumentBody(doc.key, activeDraft?.body ?? doc.body)}
|
onClick={() => void copyDocumentBody(doc.key, displayedBody)}
|
||||||
>
|
>
|
||||||
{copiedDocumentKey === doc.key ? (
|
{copiedDocumentKey === doc.key ? (
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
|
@ -688,7 +836,7 @@ export function IssueDocumentsSection({
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => downloadDocumentFile(doc.key, activeDraft?.body ?? doc.body)}
|
onClick={() => downloadDocumentFile(doc.key, displayedBody)}
|
||||||
>
|
>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
Download document
|
Download document
|
||||||
|
|
@ -711,23 +859,64 @@ export function IssueDocumentsSection({
|
||||||
{!isFolded ? (
|
{!isFolded ? (
|
||||||
<div
|
<div
|
||||||
className="mt-3 space-y-3"
|
className="mt-3 space-y-3"
|
||||||
onFocusCapture={() => {
|
onFocusCapture={!isHistoricalPreview
|
||||||
if (!activeDraft) {
|
? () => {
|
||||||
beginEdit(doc.key);
|
if (!activeDraft) {
|
||||||
}
|
beginEdit(doc.key);
|
||||||
}}
|
}
|
||||||
onBlurCapture={async (event) => {
|
}
|
||||||
if (activeDraft) {
|
: undefined}
|
||||||
await handleDraftBlur(event);
|
onBlurCapture={!isHistoricalPreview
|
||||||
}
|
? async (event) => {
|
||||||
}}
|
if (activeDraft) {
|
||||||
onKeyDown={async (event) => {
|
await handleDraftBlur(event);
|
||||||
if (activeDraft) {
|
}
|
||||||
await handleDraftKeyDown(event);
|
}
|
||||||
}
|
: undefined}
|
||||||
}}
|
onKeyDown={!isHistoricalPreview
|
||||||
|
? async (event) => {
|
||||||
|
if (activeDraft) {
|
||||||
|
await handleDraftKeyDown(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined}
|
||||||
>
|
>
|
||||||
{activeConflict && (
|
{isHistoricalPreview && selectedHistoricalRevision && (
|
||||||
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-amber-200">
|
||||||
|
Viewing revision {selectedHistoricalRevision.revisionNumber}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This is a historical preview. Restoring it creates a new latest revision and keeps history append-only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => returnToLatestRevision(doc.key)}
|
||||||
|
>
|
||||||
|
Return to latest
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => restoreDocumentRevision.mutate({
|
||||||
|
key: doc.key,
|
||||||
|
revisionId: selectedHistoricalRevision.id,
|
||||||
|
})}
|
||||||
|
disabled={restoreDocumentRevision.isPending}
|
||||||
|
>
|
||||||
|
{restoreDocumentRevision.isPending && restoreDocumentRevision.variables?.key === doc.key
|
||||||
|
? "Restoring..."
|
||||||
|
: "Restore this revision"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeConflict && !isHistoricalPreview && (
|
||||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -788,7 +977,7 @@ export function IssueDocumentsSection({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeDraft && !isPlanKey(doc.key) && (
|
{activeDraft && !isPlanKey(doc.key) && !isHistoricalPreview && (
|
||||||
<Input
|
<Input
|
||||||
value={activeDraft.title}
|
value={activeDraft.title}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
|
|
@ -800,47 +989,57 @@ export function IssueDocumentsSection({
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
|
className={`${documentBodyShellClassName} ${documentBodyPaddingClassName} ${
|
||||||
activeDraft ? "" : "hover:bg-accent/10"
|
activeDraft || isHistoricalPreview ? "" : "hover:bg-accent/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MarkdownEditor
|
{isHistoricalPreview ? (
|
||||||
value={activeDraft?.body ?? doc.body}
|
<div className="rounded-md border border-amber-500/20 bg-background/50 p-3">
|
||||||
onChange={(body) => {
|
{renderBody(displayedBody, documentBodyContentClassName)}
|
||||||
markDocumentDirty(doc.key);
|
</div>
|
||||||
setDraft((current) => {
|
) : (
|
||||||
if (current && current.key === doc.key && !current.isNew) {
|
<MarkdownEditor
|
||||||
return { ...current, body };
|
value={displayedBody}
|
||||||
}
|
onChange={(body) => {
|
||||||
return {
|
markDocumentDirty(doc.key);
|
||||||
key: doc.key,
|
setDraft((current) => {
|
||||||
title: doc.title ?? "",
|
if (current && current.key === doc.key && !current.isNew) {
|
||||||
body,
|
return { ...current, body };
|
||||||
baseRevisionId: doc.latestRevisionId,
|
}
|
||||||
isNew: false,
|
return {
|
||||||
};
|
key: doc.key,
|
||||||
});
|
title: doc.title ?? "",
|
||||||
}}
|
body,
|
||||||
placeholder="Markdown body"
|
baseRevisionId: doc.latestRevisionId,
|
||||||
bordered={false}
|
isNew: false,
|
||||||
className="bg-transparent"
|
};
|
||||||
contentClassName={documentBodyContentClassName}
|
});
|
||||||
mentions={mentions}
|
}}
|
||||||
imageUploadHandler={imageUploadHandler}
|
placeholder="Markdown body"
|
||||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
bordered={false}
|
||||||
/>
|
className="bg-transparent"
|
||||||
|
contentClassName={documentBodyContentClassName}
|
||||||
|
mentions={mentions}
|
||||||
|
imageUploadHandler={imageUploadHandler}
|
||||||
|
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-4 items-center justify-end px-1">
|
<div className="flex min-h-4 items-center justify-end px-1">
|
||||||
<span
|
<span
|
||||||
className={`text-[11px] transition-opacity duration-150 ${
|
className={`text-[11px] transition-opacity duration-150 ${
|
||||||
activeConflict
|
isHistoricalPreview
|
||||||
|
? "text-amber-300"
|
||||||
|
: activeConflict
|
||||||
? "text-amber-300"
|
? "text-amber-300"
|
||||||
: autosaveState === "error"
|
: autosaveState === "error"
|
||||||
? "text-destructive"
|
? "text-destructive"
|
||||||
: "text-muted-foreground"
|
: "text-muted-foreground"
|
||||||
} ${activeDraft ? "opacity-100" : "opacity-0"}`}
|
} ${activeDraft || isHistoricalPreview ? "opacity-100" : "opacity-0"}`}
|
||||||
>
|
>
|
||||||
{activeDraft
|
{isHistoricalPreview
|
||||||
? activeConflict
|
? "Viewing historical revision"
|
||||||
|
: activeDraft
|
||||||
|
? activeConflict
|
||||||
? "Out of date"
|
? "Out of date"
|
||||||
: autosaveDocumentKey === doc.key
|
: autosaveDocumentKey === doc.key
|
||||||
? autosaveState === "saving"
|
? autosaveState === "saving"
|
||||||
|
|
@ -851,7 +1050,7 @@ export function IssueDocumentsSection({
|
||||||
? "Could not save"
|
? "Could not save"
|
||||||
: ""
|
: ""
|
||||||
: ""
|
: ""
|
||||||
: ""}
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue