From 4036d64996f55ac7e95d6ab546db67b539561005 Mon Sep 17 00:00:00 2001 From: yushen Date: Sun, 5 Apr 2026 07:55:17 +0800 Subject: [PATCH] fix(attachment): use UUIDv7 as S3 key and link attachments on issue/comment creation - Use google/uuid NewV7() for attachment ID and S3 file key instead of random hex, so the S3 object name matches the attachment record ID - Add LinkAttachmentsToIssue query to associate orphaned attachments with a newly created issue - Pass attachment_ids in CreateIssue request so uploads during issue creation (before the issue exists) get linked after commit - Collect and pass attachment IDs in comment-input and reply-input so comment creation properly links uploaded files Co-Authored-By: Claude Opus 4.6 (1M context) --- .../issues/components/comment-input.tsx | 10 ++++- .../issues/components/reply-input.tsx | 10 ++++- apps/web/features/modals/create-issue.tsx | 12 +++++- apps/web/shared/types/api.ts | 1 + server/go.mod | 1 + server/go.sum | 2 + server/internal/handler/file.go | 29 ++++++++++++--- server/internal/handler/issue.go | 37 +++++++++++++++---- server/pkg/db/generated/attachment.sql.go | 25 ++++++++++++- server/pkg/db/queries/attachment.sql | 11 +++++- 10 files changed, 114 insertions(+), 24 deletions(-) diff --git a/apps/web/features/issues/components/comment-input.tsx b/apps/web/features/issues/components/comment-input.tsx index b88d2500..29558543 100644 --- a/apps/web/features/issues/components/comment-input.tsx +++ b/apps/web/features/issues/components/comment-input.tsx @@ -16,10 +16,15 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) { const editorRef = useRef(null); const [isEmpty, setIsEmpty] = useState(true); const [submitting, setSubmitting] = useState(false); + const [attachmentIds, setAttachmentIds] = useState([]); const { uploadWithToast } = useFileUpload(); const handleUpload = async (file: File) => { - return await uploadWithToast(file, { issueId }); + const result = await uploadWithToast(file, { issueId }); + if (result) { + setAttachmentIds((prev) => [...prev, result.id]); + } + return result; }; const handleSubmit = async () => { @@ -27,9 +32,10 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) { if (!content || submitting) return; setSubmitting(true); try { - await onSubmit(content); + await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined); editorRef.current?.clearContent(); setIsEmpty(true); + setAttachmentIds([]); } finally { setSubmitting(false); } diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index b82b424d..5fe553e1 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -38,6 +38,7 @@ function ReplyInput({ const [isEmpty, setIsEmpty] = useState(true); const [isExpanded, setIsExpanded] = useState(false); const [submitting, setSubmitting] = useState(false); + const [attachmentIds, setAttachmentIds] = useState([]); const { uploadWithToast } = useFileUpload(); useEffect(() => { @@ -52,7 +53,11 @@ function ReplyInput({ }, []); const handleUpload = async (file: File) => { - return await uploadWithToast(file, { issueId }); + const result = await uploadWithToast(file, { issueId }); + if (result) { + setAttachmentIds((prev) => [...prev, result.id]); + } + return result; }; const handleSubmit = async () => { @@ -60,9 +65,10 @@ function ReplyInput({ if (!content || submitting) return; setSubmitting(true); try { - await onSubmit(content); + await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined); editorRef.current?.clearContent(); setIsEmpty(true); + setAttachmentIds([]); } finally { setSubmitting(false); } diff --git a/apps/web/features/modals/create-issue.tsx b/apps/web/features/modals/create-issue.tsx index c2d13059..8dbf47dd 100644 --- a/apps/web/features/modals/create-issue.tsx +++ b/apps/web/features/modals/create-issue.tsx @@ -93,9 +93,16 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? // Due date popover const [dueDateOpen, setDueDateOpen] = useState(false); - // File upload + // File upload — collect attachment IDs so we can link them after issue creation. + const [attachmentIds, setAttachmentIds] = useState([]); const { uploadWithToast } = useFileUpload(); - const handleUpload = (file: File) => uploadWithToast(file); + const handleUpload = async (file: File) => { + const result = await uploadWithToast(file); + if (result) { + setAttachmentIds((prev) => [...prev, result.id]); + } + return result; + }; const assigneeQuery = assigneeFilter.toLowerCase(); const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery)); @@ -130,6 +137,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? assignee_type: assigneeType, assignee_id: assigneeId, due_date: dueDate || undefined, + attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined, }); useIssueStore.getState().addIssue(issue); clearDraft(); diff --git a/apps/web/shared/types/api.ts b/apps/web/shared/types/api.ts index cdeeae4e..882750bc 100644 --- a/apps/web/shared/types/api.ts +++ b/apps/web/shared/types/api.ts @@ -11,6 +11,7 @@ export interface CreateIssueRequest { assignee_id?: string; parent_issue_id?: string; due_date?: string; + attachment_ids?: string[]; } export interface UpdateIssueRequest { diff --git a/server/go.mod b/server/go.mod index 30725711..3bcaec56 100644 --- a/server/go.mod +++ b/server/go.mod @@ -34,6 +34,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.24.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/server/go.sum b/server/go.sum index 7017544c..66dea654 100644 --- a/server/go.sum +++ b/server/go.sum @@ -48,6 +48,8 @@ github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index e41e8da9..75069558 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -2,8 +2,6 @@ package handler import ( "context" - "crypto/rand" - "encoding/hex" "fmt" "io" "log/slog" @@ -12,6 +10,7 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" ) @@ -134,13 +133,14 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { return } - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - slog.Error("failed to generate file key", "error", err) + // Generate a UUIDv7 to use as both the attachment ID and S3 key. + id, err := uuid.NewV7() + if err != nil { + slog.Error("failed to generate uuid", "error", err) writeError(w, http.StatusInternalServerError, "internal error") return } - key := hex.EncodeToString(b) + path.Ext(header.Filename) + key := id.String() + path.Ext(header.Filename) link, err := h.Storage.Upload(r.Context(), key, data, contentType, header.Filename) if err != nil { @@ -154,6 +154,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { uploaderType, uploaderID := h.resolveActor(r, userID, workspaceID) params := db.CreateAttachmentParams{ + ID: pgtype.UUID{Bytes: id, Valid: true}, WorkspaceID: parseUUID(workspaceID), UploaderType: uploaderType, UploaderID: parseUUID(uploaderID), @@ -295,6 +296,22 @@ func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) { // Attachment linking // --------------------------------------------------------------------------- +// linkAttachmentsByIssueIDs links the given attachment IDs to an issue. +// Only updates attachments that have no issue_id yet. +func (h *Handler) linkAttachmentsByIssueIDs(ctx context.Context, issueID, workspaceID pgtype.UUID, ids []string) { + uuids := make([]pgtype.UUID, len(ids)) + for i, id := range ids { + uuids[i] = parseUUID(id) + } + if err := h.Queries.LinkAttachmentsToIssue(ctx, db.LinkAttachmentsToIssueParams{ + IssueID: issueID, + WorkspaceID: workspaceID, + Column3: uuids, + }); err != nil { + slog.Error("failed to link attachments to issue", "error", err) + } +} + // 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) { diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 418252e2..0259bb21 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -170,14 +170,15 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) { } type CreateIssueRequest struct { - Title string `json:"title"` - Description *string `json:"description"` - Status string `json:"status"` - Priority string `json:"priority"` - AssigneeType *string `json:"assignee_type"` - AssigneeID *string `json:"assignee_id"` - ParentIssueID *string `json:"parent_issue_id"` - DueDate *string `json:"due_date"` + Title string `json:"title"` + Description *string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + AssigneeType *string `json:"assignee_type"` + AssigneeID *string `json:"assignee_id"` + ParentIssueID *string `json:"parent_issue_id"` + DueDate *string `json:"due_date"` + AttachmentIDs []string `json:"attachment_ids,omitempty"` } func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { @@ -287,8 +288,28 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { return } + // Link any pre-uploaded attachments to this issue. + if len(req.AttachmentIDs) > 0 { + h.linkAttachmentsByIssueIDs(r.Context(), issue.ID, issue.WorkspaceID, req.AttachmentIDs) + } + prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) resp := issueToResponse(issue, prefix) + + // Fetch linked attachments so they appear in the response. + if len(req.AttachmentIDs) > 0 { + attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{ + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + }) + if err == nil && len(attachments) > 0 { + resp.Attachments = make([]AttachmentResponse, len(attachments)) + for i, a := range attachments { + resp.Attachments[i] = h.attachmentToResponse(a) + } + } + } + slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...) h.publish(protocol.EventIssueCreated, workspaceID, creatorType, actualCreatorID, map[string]any{"issue": resp}) diff --git a/server/pkg/db/generated/attachment.sql.go b/server/pkg/db/generated/attachment.sql.go index 69d5f3de..966e3dae 100644 --- a/server/pkg/db/generated/attachment.sql.go +++ b/server/pkg/db/generated/attachment.sql.go @@ -12,12 +12,13 @@ import ( ) const createAttachment = `-- name: CreateAttachment :one -INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes) -VALUES ($1, $8, $9, $2, $3, $4, $5, $6, $7) +INSERT INTO attachment (id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes) +VALUES ($1, $2, $9, $10, $3, $4, $5, $6, $7, $8) RETURNING id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at ` type CreateAttachmentParams struct { + ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` UploaderType string `json:"uploader_type"` UploaderID pgtype.UUID `json:"uploader_id"` @@ -31,6 +32,7 @@ type CreateAttachmentParams struct { func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (Attachment, error) { row := q.db.QueryRow(ctx, createAttachment, + arg.ID, arg.WorkspaceID, arg.UploaderType, arg.UploaderID, @@ -120,6 +122,25 @@ func (q *Queries) LinkAttachmentsToComment(ctx context.Context, arg LinkAttachme return err } +const linkAttachmentsToIssue = `-- name: LinkAttachmentsToIssue :exec +UPDATE attachment +SET issue_id = $1 +WHERE workspace_id = $2 + AND issue_id IS NULL + AND id = ANY($3::uuid[]) +` + +type LinkAttachmentsToIssueParams struct { + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Column3 []pgtype.UUID `json:"column_3"` +} + +func (q *Queries) LinkAttachmentsToIssue(ctx context.Context, arg LinkAttachmentsToIssueParams) error { + _, err := q.db.Exec(ctx, linkAttachmentsToIssue, arg.IssueID, arg.WorkspaceID, arg.Column3) + return err +} + const listAttachmentURLsByCommentID = `-- name: ListAttachmentURLsByCommentID :many SELECT url FROM attachment WHERE comment_id = $1 diff --git a/server/pkg/db/queries/attachment.sql b/server/pkg/db/queries/attachment.sql index fc5a710d..1ba0a3e0 100644 --- a/server/pkg/db/queries/attachment.sql +++ b/server/pkg/db/queries/attachment.sql @@ -1,6 +1,6 @@ -- name: CreateAttachment :one -INSERT INTO attachment (workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes) -VALUES ($1, sqlc.narg(issue_id), sqlc.narg(comment_id), $2, $3, $4, $5, $6, $7) +INSERT INTO attachment (id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes) +VALUES ($1, $2, sqlc.narg(issue_id), sqlc.narg(comment_id), $3, $4, $5, $6, $7, $8) RETURNING *; -- name: ListAttachmentsByIssue :many @@ -38,5 +38,12 @@ WHERE issue_id = $2 AND comment_id IS NULL AND id = ANY($3::uuid[]); +-- name: LinkAttachmentsToIssue :exec +UPDATE attachment +SET issue_id = $1 +WHERE workspace_id = $2 + AND issue_id IS NULL + AND id = ANY($3::uuid[]); + -- name: DeleteAttachment :exec DELETE FROM attachment WHERE id = $1 AND workspace_id = $2;