Merge pull request #426 from multica-ai/fix/attachment-upload-linking
fix(attachment): use UUIDv7 as S3 key and link attachments on issue/comment creation
This commit is contained in:
commit
09565bc40f
10 changed files with 114 additions and 24 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export interface CreateIssueRequest {
|
|||
assignee_id?: string;
|
||||
parent_issue_id?: string;
|
||||
due_date?: string;
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateIssueRequest {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue