diff --git a/server/src/__tests__/document-annotations-service.test.ts b/server/src/__tests__/document-annotations-service.test.ts index ff616b65..054e57db 100644 --- a/server/src/__tests__/document-annotations-service.test.ts +++ b/server/src/__tests__/document-annotations-service.test.ts @@ -230,4 +230,44 @@ describeEmbeddedPostgres("documentAnnotationService", () => { expect(updatedThread?.status).toBe("resolved"); expect(updatedThread?.resolvedByUserId).toBe("board-user"); }); + + it("rejects annotation comments linked to already-deleted issue comments", async () => { + const { companyId, issueId, document } = await createIssueWithDocument(); + const [issueComment] = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorType: "user", + authorUserId: "board-user", + body: "", + deletedAt: new Date("2026-06-05T03:00:00.000Z"), + deletedByType: "user", + deletedByUserId: "board-user", + }) + .returning(); + + await expect( + annotations.createThread( + issueId, + "plan", + { + baseRevisionId: document.latestRevisionId!, + baseRevisionNumber: document.latestRevisionNumber, + selector: { + quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" }, + position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 }, + }, + body: "Do not link this annotation to a deleted comment", + issueCommentId: issueComment.id, + }, + { actorType: "user", actorId: "board-user", userId: "board-user" }, + ), + ).rejects.toMatchObject({ + status: 422, + message: "Linked issue comment must belong to this issue", + }); + + await expect(db.select().from(documentAnnotationComments)).resolves.toHaveLength(0); + }); }); diff --git a/server/src/services/document-annotations.ts b/server/src/services/document-annotations.ts index f7aa70e3..3de9cf02 100644 --- a/server/src/services/document-annotations.ts +++ b/server/src/services/document-annotations.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, inArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { documentAnnotationAnchorSnapshots, @@ -155,7 +155,7 @@ export function documentAnnotationService(db: Db) { issueId: issueComments.issueId, }) .from(issueComments) - .where(eq(issueComments.id, commentId)) + .where(and(eq(issueComments.id, commentId), isNull(issueComments.deletedAt))) .then((rows: Array<{ id: string; companyId: string; issueId: string }>) => rows[0] ?? null); if (!comment || comment.issueId !== issueId) { throw unprocessable("Linked issue comment must belong to this issue"); diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index c4af6461..5afbe981 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -1270,7 +1270,6 @@ describe("IssueChatThread", () => { it("confirms and invokes delete only for the current user's normal comments", async () => { const root = createRoot(container); const onDeleteComment = vi.fn(async () => {}); - const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); act(() => { root.render( @@ -1324,10 +1323,17 @@ describe("IssueChatThread", () => { deleteButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(confirmSpy).toHaveBeenCalled(); + expect(document.body.textContent).toContain("Delete comment?"); + const confirmButton = Array.from(document.body.querySelectorAll("button")) + .find((button) => button.textContent === "Delete comment"); + expect(confirmButton).toBeTruthy(); + + await act(async () => { + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onDeleteComment).toHaveBeenCalledWith("comment-owned"); - confirmSpy.mockRestore(); act(() => { root.unmount(); }); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index ef5576bc..7470ae4f 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -1290,6 +1290,7 @@ function IssueChatUserMessage({ const deleted = Boolean(custom.deletedAt); const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; const [copied, setCopied] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { isCurrentUser, authorName: resolvedAuthorName, @@ -1309,8 +1310,11 @@ function IssueChatUserMessage({ const canDeleteComment = Boolean(onDeleteComment && isCurrentUser && !queued && !pending && !deleted); const handleDeleteComment = () => { if (!canDeleteComment) return; - const confirmed = window.confirm("Delete this comment? This will replace it with a deleted-comment marker."); - if (!confirmed) return; + setDeleteDialogOpen(true); + }; + const confirmDeleteComment = () => { + if (!canDeleteComment) return; + setDeleteDialogOpen(false); void onDeleteComment?.(commentId); }; const messageBody = ( @@ -1432,21 +1436,41 @@ function IssueChatUserMessage({ ); return ( -