Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
755d8fad98 feat(issues): close composer attachment preview loop end-to-end
Text/code attachments (markdown, JSON, .ts, .log, …) need an attachment id
to render through `/api/attachments/{id}/content`. The composer pipeline
was dropping that id at the upload-hook boundary, so the Eye preview gate
only fired for media (PDF / video / audio via filename fallback).

- `useFileUpload` now returns the full `Attachment` (with `link` kept as a
  `url` alias) so editor providers can resolve content-type and id.
- New-comment and reply composers hold a `pendingAttachments` state and
  feed it to `ContentEditor`; the active subset (those still referenced in
  the markdown) is sent on submit as before.
- Comment edit modes (CommentRow + CommentCardImpl) merge pending uploads
  with `entry.attachments` for the editor and pipe `attachment_ids` into
  `onEdit` so newly uploaded files actually bind to the comment.
- Issue description editor pushes pending `attachment_ids` on every
  debounced save and invalidates `issueKeys.attachments` so the preview
  Eye survives a refresh.
- `UpdateComment` and `UpdateIssue` handlers accept `attachment_ids` and
  call the existing `linkAttachmentsByIDs` / `linkAttachmentsByIssueIDs`
  helpers; the bind is idempotent so re-sending an existing id is safe.

Closes MUL-2153.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 14:55:41 +08:00
12 changed files with 172 additions and 59 deletions

View File

@@ -592,10 +592,10 @@ export class ApiClient {
return this.fetch("/api/assignee-frequency");
}
async updateComment(commentId: string, content: string): Promise<Comment> {
async updateComment(commentId: string, content: string, attachmentIds?: string[]): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",
body: JSON.stringify({ content }),
body: JSON.stringify({ content, attachment_ids: attachmentIds }),
});
}

View File

@@ -5,11 +5,11 @@ import type { ApiClient } from "../api/client";
import type { Attachment } from "../types";
import { MAX_FILE_SIZE } from "../constants/upload";
export interface UploadResult {
id: string;
filename: string;
link: string;
}
// Carries the full Attachment so editors that need preview metadata
// (`content_type`, `download_url`) get it directly; `link` is kept as an
// alias for `url` because many callers persist it into Markdown / avatar
// fields by that name.
export type UploadResult = Attachment & { link: string };
export interface UploadContext {
issueId?: string;
@@ -36,7 +36,7 @@ export function useFileUpload(
commentId: ctx?.commentId,
chatSessionId: ctx?.chatSessionId,
});
return { id: att.id, filename: att.filename, link: att.url };
return { ...att, link: att.url };
} finally {
setUploading(false);
}

View File

@@ -200,6 +200,13 @@ export function useUpdateIssue() {
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
// Refresh the issue's attachments cache when the description editor
// bound new uploads — the description editor reads `issueAttachments`
// to resolve text-preview Eye gates, and unlike other mutations this
// payload mutates the attachment join table.
if (vars.attachment_ids?.length) {
qc.invalidateQueries({ queryKey: issueKeys.attachments(vars.id) });
}
// Invalidate old parent's children cache
if (ctx?.parentId) {
qc.invalidateQueries({
@@ -496,8 +503,8 @@ export function useCreateComment(issueId: string) {
export function useUpdateComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
api.updateComment(commentId, content),
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds?: string[] }) =>
api.updateComment(commentId, content, attachmentIds),
onMutate: async ({ commentId, content }) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));

View File

@@ -27,6 +27,10 @@ export interface UpdateIssueRequest {
due_date?: string | null;
parent_issue_id?: string | null;
project_id?: string | null;
/** Attachment IDs to bind to this issue alongside the description update.
* Used by the description editor to register newly uploaded files so they
* surface in `issueAttachments` and keep their preview Eye on refresh. */
attachment_ids?: string[];
}
export interface ListIssuesParams {

View File

@@ -2,9 +2,28 @@ import { forwardRef, useRef, useImperativeHandle } from "react";
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import enCommon from "../../locales/en/common.json";
import enChat from "../../locales/en/chat.json";
function makeUpload(overrides: Partial<UploadResult> & { id: string; link: string; filename: string }): UploadResult {
return {
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
url: overrides.link,
download_url: overrides.link,
content_type: "image/png",
size_bytes: 1,
created_at: new Date(0).toISOString(),
...overrides,
};
}
const TEST_RESOURCES = { en: { common: enCommon, chat: enChat } };
// Track drop-zone callbacks so the test can simulate a real drop.
@@ -28,7 +47,7 @@ vi.mock("../../editor", () => ({
defaultValue?: string;
onUpdate?: (md: string) => void;
placeholder?: string;
onUploadFile?: (file: File) => Promise<{ id: string; link: string; filename: string } | null>;
onUploadFile?: (file: File) => Promise<UploadResult | null>;
},
ref: React.Ref<unknown>,
) {
@@ -95,11 +114,9 @@ function renderInput(props: Partial<React.ComponentProps<typeof ChatInput>> = {}
const onSend = props.onSend ?? vi.fn();
const onUploadFile =
props.onUploadFile ??
vi.fn(async (_file: File) => ({
id: "att-1",
link: "https://cdn.example/att-1.png",
filename: "img.png",
}));
vi.fn(async (_file: File) =>
makeUpload({ id: "att-1", link: "https://cdn.example/att-1.png", filename: "img.png" }),
);
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<ChatInput onSend={onSend} onUploadFile={onUploadFile} agentName="Multica" {...props} />
@@ -122,11 +139,9 @@ describe("ChatInput attachment wiring", () => {
it("passes attachment_ids to onSend for uploads still referenced in the content", async () => {
const onSend = vi.fn();
const onUploadFile = vi.fn(async (_file: File) => ({
id: "att-42",
link: "https://cdn.example/att-42.png",
filename: "x.png",
}));
const onUploadFile = vi.fn(async (_file: File) =>
makeUpload({ id: "att-42", link: "https://cdn.example/att-42.png", filename: "x.png" }),
);
renderInput({ onSend, onUploadFile });
// Simulate the drop → editor.uploadFile → onUploadFile happy path. The
@@ -152,8 +167,8 @@ describe("ChatInput attachment wiring", () => {
});
it("disables send while an upload is in flight, re-enables after it resolves", async () => {
let resolveUpload: (v: { id: string; link: string; filename: string }) => void;
const uploadPromise = new Promise<{ id: string; link: string; filename: string }>((res) => {
let resolveUpload: (v: UploadResult) => void;
const uploadPromise = new Promise<UploadResult>((res) => {
resolveUpload = res;
});
const onSend = vi.fn();
@@ -177,11 +192,7 @@ describe("ChatInput attachment wiring", () => {
expect(sendButton).toBeDisabled();
});
resolveUpload!({
id: "att-slow",
link: "https://cdn.example/att-slow.png",
filename: "slow.png",
});
resolveUpload!(makeUpload({ id: "att-slow", link: "https://cdn.example/att-slow.png", filename: "slow.png" }));
let sendButton: HTMLElement;
await waitFor(() => {

View File

@@ -64,7 +64,7 @@ interface CommentCardProps {
*/
canModerate?: boolean;
onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
onEdit: (commentId: string, content: string) => Promise<void>;
onEdit: (commentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
/** Toggle the resolved state on the thread root. Only invoked for root entries. */
@@ -202,7 +202,7 @@ function CommentRow({
entry: TimelineEntry;
currentUserId?: string;
canModerate?: boolean;
onEdit: (commentId: string, content: string) => Promise<void>;
onEdit: (commentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
}) {
@@ -212,6 +212,20 @@ function CommentRow({
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
const { uploadWithToast } = useFileUpload(api);
// Pending uploads from this edit pass. Merged with `entry.attachments` so
// newly uploaded text/code files get an Eye button in the edit-mode editor;
// the active subset is sent as `attachmentIds` on save so the server binds
// them to the comment (otherwise they'd remain orphaned at the issue level
// and disappear after refresh).
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
const editorAttachments = pendingAttachments.length > 0
? [...(entry.attachments ?? []), ...pendingAttachments]
: entry.attachments;
const handleEditUpload = useCallback(async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) setPendingAttachments((prev) => [...prev, result]);
return result;
}, [uploadWithToast, issueId]);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
enabled: editing,
@@ -247,6 +261,7 @@ function CommentRow({
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
setPendingAttachments([]);
clearEditDraft(editDraftKey);
};
@@ -258,12 +273,17 @@ function CommentRow({
.trim();
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
setPendingAttachments([]);
clearEditDraft(editDraftKey);
return;
}
const activeIds = pendingAttachments
.filter((a) => trimmed.includes(a.url))
.map((a) => a.id);
try {
await onEdit(entry.id, trimmed);
await onEdit(entry.id, trimmed, activeIds.length > 0 ? activeIds : undefined);
setEditing(false);
setPendingAttachments([]);
clearEditDraft(editDraftKey);
} catch {
toast.error(t(($) => $.comment.update_failed));
@@ -361,10 +381,10 @@ function CommentRow({
else clearEditDraft(editDraftKey);
}}
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
onUploadFile={handleEditUpload}
debounceMs={100}
currentIssueId={issueId}
attachments={entry.attachments}
attachments={editorAttachments}
/>
</div>
<div className="flex items-center justify-between mt-2">
@@ -429,6 +449,16 @@ function CommentCardImpl({
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
// Pending uploads from the root-comment edit pass — same rationale as CommentRow.
const [parentPendingAttachments, setParentPendingAttachments] = useState<Attachment[]>([]);
const parentEditorAttachments = parentPendingAttachments.length > 0
? [...(entry.attachments ?? []), ...parentPendingAttachments]
: entry.attachments;
const handleParentEditUpload = useCallback(async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) setParentPendingAttachments((prev) => [...prev, result]);
return result;
}, [uploadWithToast, issueId]);
const { isDragOver: parentDragOver, dropZoneProps: parentDropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
enabled: editing,
@@ -461,6 +491,7 @@ function CommentCardImpl({
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
setParentPendingAttachments([]);
clearParentEditDraft(parentEditDraftKey);
};
@@ -472,12 +503,17 @@ function CommentCardImpl({
.trim();
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
setParentPendingAttachments([]);
clearParentEditDraft(parentEditDraftKey);
return;
}
const activeIds = parentPendingAttachments
.filter((a) => trimmed.includes(a.url))
.map((a) => a.id);
try {
await onEdit(entry.id, trimmed);
await onEdit(entry.id, trimmed, activeIds.length > 0 ? activeIds : undefined);
setEditing(false);
setParentPendingAttachments([]);
clearParentEditDraft(parentEditDraftKey);
} catch {
toast.error(t(($) => $.comment.update_failed));
@@ -639,10 +675,10 @@ function CommentCardImpl({
else clearParentEditDraft(parentEditDraftKey);
}}
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
onUploadFile={handleParentEditUpload}
debounceMs={100}
currentIssueId={issueId}
attachments={entry.attachments}
attachments={parentEditorAttachments}
/>
</div>
<div className="flex items-center justify-between mt-2">

View File

@@ -9,6 +9,7 @@ import { FileUploadButton } from "@multica/ui/components/common/file-upload-butt
import { SubmitButton } from "@multica/ui/components/common/submit-button";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import type { Attachment } from "@multica/core/types";
import { enterKey, formatShortcut, modKey } from "@multica/core/platform";
import { useCommentDraftStore } from "@multica/core/issues/stores";
import { useT } from "../../i18n";
@@ -30,7 +31,11 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const [isEmpty, setIsEmpty] = useState(() => !initialDraft?.trim());
const [submitting, setSubmitting] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const uploadMapRef = useRef<Map<string, string>>(new Map());
// Attachments uploaded in this composer session. Drives both:
// - submit-time `attachment_ids` payload (filtered to URLs still in markdown)
// - the editor's AttachmentDownloadProvider, so file-card Eye buttons can
// resolve text/code/markdown previews that require the attachment id.
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
const { uploadWithToast } = useFileUpload(api);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
@@ -59,7 +64,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const handleUpload = useCallback(async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) {
uploadMapRef.current.set(result.link, result.id);
setPendingAttachments((prev) => [...prev, result]);
}
return result;
}, [uploadWithToast, issueId]);
@@ -68,16 +73,15 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
// Only send attachment IDs for uploads still present in the content.
const activeIds: string[] = [];
for (const [url, id] of uploadMapRef.current) {
if (content.includes(url)) activeIds.push(id);
}
const activeIds = pendingAttachments
.filter((a) => content.includes(a.url))
.map((a) => a.id);
setSubmitting(true);
try {
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
uploadMapRef.current.clear();
setPendingAttachments([]);
clearDraft(draftKey);
} finally {
setSubmitting(false);
@@ -108,6 +112,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
onUploadFile={handleUpload}
debounceMs={100}
currentIssueId={issueId}
attachments={pendingAttachments}
/>
</div>
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">

View File

@@ -38,7 +38,7 @@ import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, Command
import { AvatarGroup, AvatarGroupCount } from "@multica/ui/components/ui/avatar";
import { ActorAvatar } from "../../common/actor-avatar";
import { PropRow } from "../../common/prop-row";
import type { Issue, IssueStatus, IssuePriority, TimelineEntry, UpdateIssueRequest } from "@multica/core/types";
import type { Attachment, Issue, IssueStatus, IssuePriority, TimelineEntry, UpdateIssueRequest } from "@multica/core/types";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { toast } from "sonner";
@@ -816,10 +816,21 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => descEditorRef.current?.uploadFile(f)),
});
// Description uploads don't pass issueId — the URL lives in the markdown.
// This avoids stale attachment records when users delete images from the editor.
// Pending uploads in the description editor. We don't pass `issueId` on
// upload (to avoid orphaning attachments when the user deletes the file
// from the markdown), so they start unattached and we re-bind them via
// `attachment_ids` on the next description save. Drives editor previews
// so text/code attachments show an Eye before the bind round-trips.
const [descPendingAttachments, setDescPendingAttachments] = useState<Attachment[]>([]);
const descEditorAttachments = descPendingAttachments.length > 0
? [...(issueAttachments ?? []), ...descPendingAttachments]
: issueAttachments;
const handleDescriptionUpload = useCallback(
(file: File) => uploadWithToast(file),
async (file: File) => {
const result = await uploadWithToast(file);
if (result) setDescPendingAttachments((prev) => [...prev, result]);
return result;
},
[uploadWithToast],
);
@@ -1287,11 +1298,19 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
key={id}
defaultValue={issue.description || ""}
placeholder={t(($) => $.detail.desc_placeholder)}
onUpdate={(md) => handleUpdateField({ description: md })}
onUpdate={(md) => {
// Bind any pending uploads still referenced in the markdown
// so they appear in `issueAttachments` after refresh and the
// editor's text/code preview keeps working past reload.
const ids = descPendingAttachments
.filter((a) => md.includes(a.url))
.map((a) => a.id);
handleUpdateField({ description: md, attachment_ids: ids.length > 0 ? ids : undefined });
}}
onUploadFile={handleDescriptionUpload}
debounceMs={1500}
currentIssueId={id}
attachments={issueAttachments}
attachments={descEditorAttachments}
/>
<div className="flex items-center gap-1 mt-3">

View File

@@ -8,6 +8,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
import { ActorAvatar } from "../../common/actor-avatar";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import type { Attachment } from "@multica/core/types";
import { useCommentDraftStore, type CommentDraftKey } from "@multica/core/issues/stores";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
@@ -55,7 +56,9 @@ function ReplyInput({
const [isEmpty, setIsEmpty] = useState(!initialDraft?.trim());
const [isExpanded, setIsExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const uploadMapRef = useRef<Map<string, string>>(new Map());
// Attachments uploaded in this composer session — see CommentInput for the
// rationale (drives both submit-time attachment_ids and editor previews).
const [pendingAttachments, setPendingAttachments] = useState<Attachment[]>([]);
const { uploadWithToast } = useFileUpload(api);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
@@ -80,7 +83,7 @@ function ReplyInput({
const handleUpload = useCallback(async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) {
uploadMapRef.current.set(result.link, result.id);
setPendingAttachments((prev) => [...prev, result]);
}
return result;
}, [uploadWithToast, issueId]);
@@ -89,16 +92,15 @@ function ReplyInput({
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
// Only send attachment IDs for uploads still present in the content.
const activeIds: string[] = [];
for (const [url, id] of uploadMapRef.current) {
if (content.includes(url)) activeIds.push(id);
}
const activeIds = pendingAttachments
.filter((a) => content.includes(a.url))
.map((a) => a.id);
setSubmitting(true);
try {
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
uploadMapRef.current.clear();
setPendingAttachments([]);
if (draftKey) clearDraft(draftKey);
} finally {
setSubmitting(false);
@@ -141,6 +143,7 @@ function ReplyInput({
onUploadFile={handleUpload}
debounceMs={100}
currentIssueId={issueId}
attachments={pendingAttachments}
/>
</div>
<div className="absolute bottom-0 right-0 flex items-center gap-1">

View File

@@ -292,9 +292,9 @@ export function useIssueTimeline(issueId: string, userId?: string) {
);
const editComment = useCallback(
async (commentId: string, content: string) => {
async (commentId: string, content: string, attachmentIds?: string[]) => {
try {
await updateComment({ commentId, content });
await updateComment({ commentId, content, attachmentIds });
} catch {
toast.error(t(($) => $.comment.update_failed));
}

View File

@@ -509,7 +509,8 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
}
var req struct {
Content string `json:"content"`
Content string `json:"content"`
AttachmentIDs []string `json:"attachment_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
@@ -520,6 +521,11 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
return
}
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
if !ok {
return
}
// NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here.
comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{
@@ -532,6 +538,14 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
return
}
// Bind any newly uploaded attachments referenced in the edited content so
// they appear in the timeline's comment.attachments after refresh. Existing
// attachments already point at this comment via the upload flow; passing
// them again is a no-op at the SQL level.
if len(attachmentIDs) > 0 {
h.linkAttachmentsByIDs(r.Context(), comment.ID, existing.IssueID, attachmentIDs)
}
// Fetch reactions and attachments for the updated comment.
grouped := h.groupReactions(r, []pgtype.UUID{comment.ID})
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})

View File

@@ -1416,6 +1416,11 @@ type UpdateIssueRequest struct {
DueDate *string `json:"due_date"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
// AttachmentIDs lets the description editor bind newly uploaded files to
// this issue so they surface in `GET /api/issues/:id/attachments` and the
// editor's preview Eye keeps working past a refresh. Existing bindings
// are idempotent — re-sending the same id is a no-op.
AttachmentIDs []string `json:"attachment_ids"`
}
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
@@ -1564,6 +1569,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
}
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
if !ok {
return
}
issue, err := h.Queries.UpdateIssue(r.Context(), params)
if err != nil {
slog.Warn("update issue failed", append(logger.RequestAttrs(r), "error", err, "issue_id", id, "workspace_id", workspaceID)...)
@@ -1571,6 +1581,10 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
return
}
if len(attachmentIDs) > 0 {
h.linkAttachmentsByIssueIDs(r.Context(), issue.ID, issue.WorkspaceID, attachmentIDs)
}
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
resp := issueToResponse(issue, prefix)
slog.Info("issue updated", append(logger.RequestAttrs(r), "issue_id", id, "workspace_id", workspaceID)...)