fix(upload): link attachments to comments via client-side ID tracking

Instead of regex-parsing markdown content to find attachment URLs
(fragile), the frontend now tracks uploaded attachment IDs and sends
them with the comment creation request. The backend links them by ID.

Frontend: upload returns attachment ID, comment/reply inputs collect
IDs during editing session, pass as attachment_ids on submit.
Backend: CreateComment accepts attachment_ids, links by ID+issue scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yushen 2026-03-31 16:47:27 +08:00
parent acba0b8139
commit 79cd2a3a5d
11 changed files with 85 additions and 35 deletions

View file

@ -32,7 +32,7 @@ interface CommentCardProps {
entry: TimelineEntry;
allReplies: Map<string, TimelineEntry[]>;
currentUserId?: string;
onReply: (parentId: string, content: string) => Promise<void>;
onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
onEdit: (commentId: string, content: string) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
@ -374,7 +374,7 @@ function CommentCard({
size="sm"
avatarType="member"
avatarId={currentUserId ?? ""}
onSubmit={(content) => onReply(entry.id, content)}
onSubmit={(content, attachmentIds) => onReply(entry.id, content, attachmentIds)}
/>
</div>
</CollapsibleContent>

View file

@ -8,17 +8,22 @@ import { useFileUpload } from "@/shared/hooks/use-file-upload";
interface CommentInputProps {
issueId: string;
onSubmit: (content: string) => Promise<void>;
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
}
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<RichTextEditorRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const attachmentIdsRef = useRef<string[]>([]);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { uploadWithToast, uploading } = useFileUpload();
const handleUpload = (file: File) => uploadWithToast(file, { issueId });
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) attachmentIdsRef.current.push(result.id);
return result;
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@ -35,8 +40,10 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
if (!content || submitting) return;
setSubmitting(true);
try {
await onSubmit(content);
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
await onSubmit(content, ids);
editorRef.current?.clearContent();
attachmentIdsRef.current = [];
setIsEmpty(true);
} finally {
setSubmitting(false);

View file

@ -16,7 +16,7 @@ interface ReplyInputProps {
placeholder?: string;
avatarType: string;
avatarId: string;
onSubmit: (content: string) => Promise<void>;
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
size?: "sm" | "default";
}
@ -34,11 +34,16 @@ function ReplyInput({
}: ReplyInputProps) {
const editorRef = useRef<RichTextEditorRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const attachmentIdsRef = useRef<string[]>([]);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { uploadWithToast, uploading } = useFileUpload();
const handleUpload = (file: File) => uploadWithToast(file, { issueId });
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) attachmentIdsRef.current.push(result.id);
return result;
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@ -55,8 +60,10 @@ function ReplyInput({
if (!content || submitting) return;
setSubmitting(true);
try {
await onSubmit(content);
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
await onSubmit(content, ids);
editorRef.current?.clearContent();
attachmentIdsRef.current = [];
setIsEmpty(true);
} finally {
setSubmitting(false);

View file

@ -174,11 +174,11 @@ export function useIssueTimeline(issueId: string, userId?: string) {
// --- Mutation functions ---
const submitComment = useCallback(
async (content: string) => {
async (content: string, attachmentIds?: string[]) => {
if (!content.trim() || submitting || !userId) return;
setSubmitting(true);
try {
const comment = await api.createComment(issueId, content);
const comment = await api.createComment(issueId, content, undefined, undefined, attachmentIds);
setTimeline((prev) => {
if (prev.some((e) => e.id === comment.id)) return prev;
return [...prev, commentToTimelineEntry(comment)];
@ -193,10 +193,10 @@ export function useIssueTimeline(issueId: string, userId?: string) {
);
const submitReply = useCallback(
async (parentId: string, content: string) => {
async (parentId: string, content: string, attachmentIds?: string[]) => {
if (!content.trim() || !userId) return;
try {
const comment = await api.createComment(issueId, content, "comment", parentId);
const comment = await api.createComment(issueId, content, "comment", parentId, attachmentIds);
setTimeline((prev) => {
if (prev.some((e) => e.id === comment.id)) return prev;
return [...prev, commentToTimelineEntry(comment)];

View file

@ -209,13 +209,14 @@ export class ApiClient {
return this.fetch(`/api/issues/${issueId}/comments`);
}
async createComment(issueId: string, content: string, type?: string, parentId?: string): Promise<Comment> {
async createComment(issueId: string, content: string, type?: string, parentId?: string, attachmentIds?: string[]): Promise<Comment> {
return this.fetch(`/api/issues/${issueId}/comments`, {
method: "POST",
body: JSON.stringify({
content,
type: type ?? "comment",
...(parentId ? { parent_id: parentId } : {}),
...(attachmentIds?.length ? { attachment_ids: attachmentIds } : {}),
}),
});
}

View file

@ -32,6 +32,7 @@ function isAllowedType(type: string): boolean {
}
export interface UploadResult {
id: string;
filename: string;
link: string;
}
@ -59,7 +60,7 @@ export function useFileUpload() {
issueId: ctx?.issueId,
commentId: ctx?.commentId,
});
return { filename: att.filename, link: att.url };
return { id: att.id, filename: att.filename, link: att.url };
} finally {
setUploading(false);
}

View file

@ -83,9 +83,10 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
}
type CreateCommentRequest struct {
Content string `json:"content"`
Type string `json:"type"`
ParentID *string `json:"parent_id"`
Content string `json:"content"`
Type string `json:"type"`
ParentID *string `json:"parent_id"`
AttachmentIDs []string `json:"attachment_ids"`
}
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
@ -142,6 +143,11 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
return
}
// Link uploaded attachments to this comment.
if len(req.AttachmentIDs) > 0 {
h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, req.AttachmentIDs)
}
resp := commentToResponse(comment, nil, nil)
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{

View file

@ -297,6 +297,26 @@ func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Attachment linking
// ---------------------------------------------------------------------------
// linkAttachmentsByIDs links the given attachment IDs to a comment.
// Only updates attachments that belong to the same issue and have no comment_id yet.
func (h *Handler) linkAttachmentsByIDs(ctx context.Context, commentID, issueID pgtype.UUID, ids []string) {
uuids := make([]pgtype.UUID, len(ids))
for i, id := range ids {
uuids[i] = parseUUID(id)
}
if err := h.Queries.LinkAttachmentsToComment(ctx, db.LinkAttachmentsToCommentParams{
CommentID: commentID,
IssueID: issueID,
Column3: uuids,
}); err != nil {
slog.Error("failed to link attachments to comment", "error", err)
}
}
// deleteS3Object removes a single file from S3 by its CDN URL.
func (h *Handler) deleteS3Object(ctx context.Context, url string) {
if h.Storage == nil || url == "" {

View file

@ -101,6 +101,25 @@ func (q *Queries) GetAttachment(ctx context.Context, arg GetAttachmentParams) (A
return i, err
}
const linkAttachmentsToComment = `-- name: LinkAttachmentsToComment :exec
UPDATE attachment
SET comment_id = $1
WHERE issue_id = $2
AND comment_id IS NULL
AND id = ANY($3::uuid[])
`
type LinkAttachmentsToCommentParams struct {
CommentID pgtype.UUID `json:"comment_id"`
IssueID pgtype.UUID `json:"issue_id"`
Column3 []pgtype.UUID `json:"column_3"`
}
func (q *Queries) LinkAttachmentsToComment(ctx context.Context, arg LinkAttachmentsToCommentParams) error {
_, err := q.db.Exec(ctx, linkAttachmentsToComment, arg.CommentID, arg.IssueID, arg.Column3)
return err
}
const listAttachmentURLsByCommentID = `-- name: ListAttachmentURLsByCommentID :many
SELECT url FROM attachment
WHERE comment_id = $1

View file

@ -127,24 +127,6 @@ type DaemonConnection struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type DaemonPairingSession struct {
ID pgtype.UUID `json:"id"`
Token string `json:"token"`
DaemonID string `json:"daemon_id"`
DeviceName string `json:"device_name"`
RuntimeName string `json:"runtime_name"`
RuntimeType string `json:"runtime_type"`
RuntimeVersion string `json:"runtime_version"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ApprovedBy pgtype.UUID `json:"approved_by"`
Status string `json:"status"`
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
ClaimedAt pgtype.Timestamptz `json:"claimed_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type InboxItem struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`

View file

@ -31,5 +31,12 @@ WHERE a.issue_id = $1
SELECT url FROM attachment
WHERE comment_id = $1;
-- name: LinkAttachmentsToComment :exec
UPDATE attachment
SET comment_id = $1
WHERE issue_id = $2
AND comment_id IS NULL
AND id = ANY($3::uuid[]);
-- name: DeleteAttachment :exec
DELETE FROM attachment WHERE id = $1 AND workspace_id = $2;