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) <noreply@anthropic.com>
This commit is contained in:
yushen 2026-04-05 07:55:17 +08:00
parent 5b0a537302
commit 4036d64996
10 changed files with 114 additions and 24 deletions

View file

@ -16,10 +16,15 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
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);
}

View file

@ -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<string[]>([]);
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);
}

View file

@ -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<string[]>([]);
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();

View file

@ -11,6 +11,7 @@ export interface CreateIssueRequest {
assignee_id?: string;
parent_issue_id?: string;
due_date?: string;
attachment_ids?: string[];
}
export interface UpdateIssueRequest {

View file

@ -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

View file

@ -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=

View file

@ -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) {

View file

@ -178,6 +178,7 @@ type CreateIssueRequest struct {
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})

View file

@ -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

View file

@ -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;