diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index 2bea2dda..1b2cf7bf 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -32,7 +32,7 @@ interface CommentCardProps { entry: TimelineEntry; allReplies: Map; currentUserId?: string; - onReply: (parentId: string, content: string) => Promise; + onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise; onEdit: (commentId: string, content: string) => Promise; 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)} /> diff --git a/apps/web/features/issues/components/comment-input.tsx b/apps/web/features/issues/components/comment-input.tsx index 388f2c77..1f8aec67 100644 --- a/apps/web/features/issues/components/comment-input.tsx +++ b/apps/web/features/issues/components/comment-input.tsx @@ -8,17 +8,22 @@ import { useFileUpload } from "@/shared/hooks/use-file-upload"; interface CommentInputProps { issueId: string; - onSubmit: (content: string) => Promise; + onSubmit: (content: string, attachmentIds?: string[]) => Promise; } function CommentInput({ issueId, onSubmit }: CommentInputProps) { const editorRef = useRef(null); const fileInputRef = useRef(null); + const attachmentIdsRef = useRef([]); 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) => { 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); diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index 4f7f8f31..52792225 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -16,7 +16,7 @@ interface ReplyInputProps { placeholder?: string; avatarType: string; avatarId: string; - onSubmit: (content: string) => Promise; + onSubmit: (content: string, attachmentIds?: string[]) => Promise; size?: "sm" | "default"; } @@ -34,11 +34,16 @@ function ReplyInput({ }: ReplyInputProps) { const editorRef = useRef(null); const fileInputRef = useRef(null); + const attachmentIdsRef = useRef([]); 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) => { 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); diff --git a/apps/web/features/issues/hooks/use-issue-timeline.ts b/apps/web/features/issues/hooks/use-issue-timeline.ts index 427b35a7..ab2eda6a 100644 --- a/apps/web/features/issues/hooks/use-issue-timeline.ts +++ b/apps/web/features/issues/hooks/use-issue-timeline.ts @@ -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)]; diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 2419d7bc..8c778806 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -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 { + async createComment(issueId: string, content: string, type?: string, parentId?: string, attachmentIds?: string[]): Promise { 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 } : {}), }), }); } diff --git a/apps/web/shared/hooks/use-file-upload.ts b/apps/web/shared/hooks/use-file-upload.ts index ef5bafbe..476913fa 100644 --- a/apps/web/shared/hooks/use-file-upload.ts +++ b/apps/web/shared/hooks/use-file-upload.ts @@ -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); } diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 1d942d36..93cf8f3f 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -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{ diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index 85c71b48..a51711c0 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -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 == "" { diff --git a/server/pkg/db/generated/attachment.sql.go b/server/pkg/db/generated/attachment.sql.go index 4f970adc..69d5f3de 100644 --- a/server/pkg/db/generated/attachment.sql.go +++ b/server/pkg/db/generated/attachment.sql.go @@ -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 diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 9ba8f116..63f4996b 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -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"` diff --git a/server/pkg/db/queries/attachment.sql b/server/pkg/db/queries/attachment.sql index c22e20ea..fc5a710d 100644 --- a/server/pkg/db/queries/attachment.sql +++ b/server/pkg/db/queries/attachment.sql @@ -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;