d7713cb575
F4 [critical] — the anti-join `DELETE … WHERE NOT EXISTS(child)` was still racy under Postgres READ COMMITTED: a reply INSERT holds FOR KEY SHARE on the parent; the DELETE's start snapshot doesn't see the uncommitted child (NOT EXISTS true), blocks on the reply's lock, and when the reply commits the parent was only LOCKED (not modified) so EvalPlanQual does NOT re-check → the DELETE proceeds and CASCADE destroys the just-committed reply. Replaced with a transaction: SELECT the parent FOR UPDATE (conflicts with the reply's FOR KEY SHARE → serializes the concurrent reply), re-check for a child with a FRESH statement in the same tx (a new RC snapshot sees a just-committed reply), delete only if still childless (return 1) else return 0 (caller resolves). The FOR UPDATE lock is held to end-of-tx so no reply can insert between the re-check and the delete. Signature unchanged, so the service + its mocked unit tests are untouched; docstrings updated. F5 [warning] — the client Dismiss button was gated only on canComment, but the server now gates dismiss on owner-or-space-admin, so a non-owner non-admin saw a button the server 403s. `canShowDismiss` now also requires `isOwnerOrAdmin = currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin"` (the same gate the comment delete-menu already uses); threaded into both call sites. F6 [warning] — added a REAL-DB int-spec (apps/server/test/integration/comment-delete-if-childless.int-spec.ts, + a createComment seeder): (a) childless → returns 1, row gone; (b) committed reply → returns 0, parent+reply survive; (c) CONCURRENCY — a second connection inserts a reply (FOR KEY SHARE) and commits mid-operation while deleteCommentIfChildless blocks on FOR UPDATE → asserts it returns 0 and both rows survive (a blind anti-join would lose the reply here). Ran against live Postgres — 3/3 pass. server tsc clean; comment jest 53 + int-spec 3 (live Postgres) pass. client tsc clean; comment vitest 56 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import classes from "./comment.module.css";
|
|
import { useAtom, useAtomValue } from "jotai";
|
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
|
import CommentActions from "@/features/comment/components/comment-actions";
|
|
import CommentMenu from "@/features/comment/components/comment-menu";
|
|
import ResolveComment from "@/features/comment/components/resolve-comment";
|
|
import { useHover } from "@mantine/hooks";
|
|
import {
|
|
useApplySuggestionMutation,
|
|
useDeleteCommentMutation,
|
|
useDismissSuggestionMutation,
|
|
useResolveCommentMutation,
|
|
useUpdateCommentMutation,
|
|
} from "@/features/comment/queries/comment-query";
|
|
import { IComment } from "@/features/comment/types/comment.types";
|
|
import { canShowApply, canShowDismiss } from "@/features/comment/utils/suggestion";
|
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
interface CommentListItemProps {
|
|
comment: IComment;
|
|
pageId: string;
|
|
canComment: boolean;
|
|
// Real page-edit permission (page.permissions.canEdit) — gates the suggestion
|
|
// "Apply" button. Distinct from `canComment`, which may be looser (viewers
|
|
// allowed to comment cannot apply edits).
|
|
canEdit?: boolean;
|
|
userSpaceRole?: string;
|
|
}
|
|
|
|
function CommentListItem({
|
|
comment,
|
|
pageId,
|
|
canComment,
|
|
canEdit,
|
|
userSpaceRole,
|
|
}: CommentListItemProps) {
|
|
const { t } = useTranslation();
|
|
const { hovered, ref } = useHover();
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const editor = useAtomValue(pageEditorAtom);
|
|
const [content, setContent] = useState<string>(comment.content);
|
|
const editContentRef = useRef<any>(null);
|
|
const updateCommentMutation = useUpdateCommentMutation();
|
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
|
const resolveCommentMutation = useResolveCommentMutation();
|
|
const applySuggestionMutation = useApplySuggestionMutation();
|
|
const dismissSuggestionMutation = useDismissSuggestionMutation();
|
|
const [currentUser] = useAtom(currentUserAtom);
|
|
const createdAtAgo = useTimeAgo(comment.createdAt);
|
|
|
|
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
|
|
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
|
|
// render an action the server will 403.
|
|
const isOwnerOrAdmin =
|
|
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
|
|
|
useEffect(() => {
|
|
setContent(comment.content);
|
|
}, [comment]);
|
|
|
|
async function handleUpdateComment() {
|
|
try {
|
|
setIsLoading(true);
|
|
const commentToUpdate = {
|
|
commentId: comment.id,
|
|
content: JSON.stringify(editContentRef.current ?? content),
|
|
};
|
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
|
if (editContentRef.current) {
|
|
setContent(editContentRef.current);
|
|
editContentRef.current = null;
|
|
}
|
|
setIsEditing(false);
|
|
} catch (error) {
|
|
console.error("Failed to update comment:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteComment() {
|
|
try {
|
|
await deleteCommentMutation.mutateAsync(comment.id);
|
|
editor?.commands.unsetComment(comment.id);
|
|
} catch (error) {
|
|
console.error("Failed to delete comment:", error);
|
|
}
|
|
}
|
|
|
|
async function handleResolveComment() {
|
|
try {
|
|
const isResolved = comment.resolvedAt != null;
|
|
await resolveCommentMutation.mutateAsync({
|
|
commentId: comment.id,
|
|
pageId: comment.pageId,
|
|
resolved: !isResolved,
|
|
});
|
|
if (editor) {
|
|
editor.commands.setCommentResolved(comment.id, !isResolved);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to toggle resolved state:", error);
|
|
}
|
|
}
|
|
|
|
async function handleApplySuggestion() {
|
|
try {
|
|
await applySuggestionMutation.mutateAsync({
|
|
commentId: comment.id,
|
|
pageId: comment.pageId,
|
|
});
|
|
} catch (error) {
|
|
// Errors surface via the mutation's onError notification (incl. 409).
|
|
console.error("Failed to apply suggestion:", error);
|
|
}
|
|
}
|
|
|
|
async function handleDismissSuggestion() {
|
|
try {
|
|
await dismissSuggestionMutation.mutateAsync({
|
|
commentId: comment.id,
|
|
pageId: comment.pageId,
|
|
});
|
|
} catch (error) {
|
|
// Idempotent races are reconciled to success in the mutation's onError;
|
|
// anything else surfaces there as a notification.
|
|
console.error("Failed to dismiss suggestion:", error);
|
|
}
|
|
}
|
|
|
|
function handleCommentClick(comment: IComment) {
|
|
const el = document.querySelector(
|
|
`.comment-mark[data-comment-id="${comment.id}"]`,
|
|
);
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
el.classList.add("comment-highlight");
|
|
setTimeout(() => {
|
|
el.classList.remove("comment-highlight");
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
function handleEditToggle() {
|
|
setIsEditing(true);
|
|
}
|
|
function cancelEdit() {
|
|
editContentRef.current = null;
|
|
setIsEditing(false);
|
|
}
|
|
|
|
return (
|
|
<Box ref={ref} pb={6}>
|
|
<Group gap="xs">
|
|
{comment.createdSource === "agent" && comment.agent ? (
|
|
<AgentAvatarStack
|
|
agent={comment.agent}
|
|
launcher={comment.launcher}
|
|
aiChatId={comment.aiChatId}
|
|
showName={false}
|
|
/>
|
|
) : (
|
|
<CustomAvatar
|
|
size="sm"
|
|
avatarUrl={comment.creator.avatarUrl}
|
|
name={comment.creator.name}
|
|
/>
|
|
)}
|
|
|
|
<div style={{ flex: 1 }}>
|
|
<Group justify="space-between" wrap="nowrap">
|
|
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
{comment.createdSource === "agent" && comment.agent ? (
|
|
<>
|
|
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
|
{comment.agent.name}
|
|
</Text>
|
|
{comment.launcher && (
|
|
<>
|
|
<Text size="xs" c="dimmed" fw={400} aria-hidden>
|
|
·
|
|
</Text>
|
|
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
|
|
{comment.launcher.name}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
|
{comment.creator.name}
|
|
</Text>
|
|
)}
|
|
</Group>
|
|
|
|
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
|
|
{!comment.parentCommentId && canComment && (
|
|
<ResolveComment
|
|
editor={editor}
|
|
commentId={comment.id}
|
|
pageId={comment.pageId}
|
|
resolvedAt={comment.resolvedAt}
|
|
/>
|
|
)}
|
|
|
|
{isOwnerOrAdmin && (
|
|
<CommentMenu
|
|
onEditComment={handleEditToggle}
|
|
onDeleteComment={handleDeleteComment}
|
|
onResolveComment={handleResolveComment}
|
|
canEdit={currentUser?.user?.id === comment.creatorId}
|
|
canComment={canComment}
|
|
isResolved={comment.resolvedAt != null}
|
|
isParentComment={!comment.parentCommentId}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Group>
|
|
|
|
<Group gap="xs">
|
|
<Text size="xs" fw={500} c="dimmed" lh={1.1}>
|
|
{createdAtAgo}
|
|
</Text>
|
|
</Group>
|
|
</div>
|
|
</Group>
|
|
|
|
<div>
|
|
{!comment.parentCommentId && comment?.selection && (
|
|
<Box
|
|
className={classes.textSelection}
|
|
onClick={() => handleCommentClick(comment)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
handleCommentClick(comment);
|
|
}
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={t("Jump to comment selection")}
|
|
>
|
|
<Text size="xs">{comment?.selection}</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Suggested-edit (#315): "было → стало" diff for a top-level comment
|
|
carrying a suggestion. Old text struck-through/red, new text green. */}
|
|
{!comment.parentCommentId && comment.suggestedText && (
|
|
<Box className={classes.suggestionBlock}>
|
|
{comment.selection && (
|
|
<Text size="xs" className={classes.suggestionOld}>
|
|
{comment.selection}
|
|
</Text>
|
|
)}
|
|
<Text size="xs" className={classes.suggestionNew}>
|
|
{comment.suggestedText}
|
|
</Text>
|
|
|
|
{comment.suggestionAppliedAt ? (
|
|
<Badge
|
|
size="sm"
|
|
color="green"
|
|
variant="light"
|
|
mt={6}
|
|
aria-label={t("Applied")}
|
|
>
|
|
{t("Applied")}
|
|
</Badge>
|
|
) : (
|
|
(canShowApply(comment, canEdit) ||
|
|
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
|
|
<Group gap="xs" mt={6}>
|
|
{canShowApply(comment, canEdit) && (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
color="green"
|
|
onClick={handleApplySuggestion}
|
|
loading={applySuggestionMutation.isPending}
|
|
disabled={
|
|
applySuggestionMutation.isPending ||
|
|
dismissSuggestionMutation.isPending
|
|
}
|
|
>
|
|
{t("Apply")}
|
|
</Button>
|
|
)}
|
|
{/* Dismiss ("Не применять", #329): removes the suggestion
|
|
without changing the page text. Gated on canComment. */}
|
|
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="subtle"
|
|
color="gray"
|
|
onClick={handleDismissSuggestion}
|
|
loading={dismissSuggestionMutation.isPending}
|
|
disabled={
|
|
applySuggestionMutation.isPending ||
|
|
dismissSuggestionMutation.isPending
|
|
}
|
|
>
|
|
{t("Dismiss")}
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
)
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{!isEditing ? (
|
|
<CommentEditor defaultContent={content} editable={false} />
|
|
) : (
|
|
<>
|
|
<CommentEditor
|
|
defaultContent={content}
|
|
editable={true}
|
|
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
|
onSave={handleUpdateComment}
|
|
autofocus={true}
|
|
/>
|
|
|
|
<CommentActions
|
|
onSave={handleUpdateComment}
|
|
isLoading={isLoading}
|
|
onCancel={cancelEdit}
|
|
isCommentEditor={true}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export default CommentListItem;
|