diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 77739728..065d8051 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -297,6 +297,7 @@ describe("IssueDetailPage", () => { author_id: "user-1", parent_id: null, reactions: [], + attachments: [], created_at: "2026-01-18T00:00:00Z", updated_at: "2026-01-18T00:00:00Z", }; diff --git a/apps/web/shared/types/activity.ts b/apps/web/shared/types/activity.ts index 5dc2e9fa..d14cbebc 100644 --- a/apps/web/shared/types/activity.ts +++ b/apps/web/shared/types/activity.ts @@ -1,4 +1,5 @@ import type { Reaction } from "./comment"; +import type { Attachment } from "./attachment"; export interface TimelineEntry { type: "activity" | "comment"; @@ -15,4 +16,5 @@ export interface TimelineEntry { updated_at?: string; comment_type?: string; reactions?: Reaction[]; + attachments?: Attachment[]; } diff --git a/apps/web/shared/types/attachment.ts b/apps/web/shared/types/attachment.ts index c69ccc44..9908850c 100644 --- a/apps/web/shared/types/attachment.ts +++ b/apps/web/shared/types/attachment.ts @@ -7,6 +7,7 @@ export interface Attachment { uploader_id: string; filename: string; url: string; + download_url: string; content_type: string; size_bytes: number; created_at: string; diff --git a/apps/web/shared/types/comment.ts b/apps/web/shared/types/comment.ts index bd2a4b57..c06c4f04 100644 --- a/apps/web/shared/types/comment.ts +++ b/apps/web/shared/types/comment.ts @@ -20,6 +20,7 @@ export interface Comment { type: CommentType; parent_id: string | null; reactions: Reaction[]; + attachments: import("./attachment").Attachment[]; created_at: string; updated_at: string; } diff --git a/server/internal/auth/cloudfront.go b/server/internal/auth/cloudfront.go index ee255378..e4cccd6d 100644 --- a/server/internal/auth/cloudfront.go +++ b/server/internal/auth/cloudfront.go @@ -172,6 +172,29 @@ func (s *CloudFrontSigner) SignedCookies(expiry time.Time) []*http.Cookie { } } +// SignedURL generates a CloudFront signed URL for the given resource URL. +// Used by CLI/API clients that don't have browser cookies. +func (s *CloudFrontSigner) SignedURL(rawURL string, expiry time.Time) string { + policy := fmt.Sprintf(`{"Statement":[{"Resource":"%s","Condition":{"DateLessThan":{"AWS:EpochTime":%d}}}]}`, rawURL, expiry.Unix()) + + encodedPolicy := cfBase64Encode([]byte(policy)) + + h := sha1.New() + h.Write([]byte(policy)) + sig, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA1, h.Sum(nil)) + if err != nil { + slog.Error("failed to sign CloudFront URL", "error", err) + return rawURL + } + encodedSig := cfBase64Encode(sig) + + separator := "?" + if strings.Contains(rawURL, "?") { + separator = "&" + } + return fmt.Sprintf("%s%sPolicy=%s&Signature=%s&Key-Pair-Id=%s", rawURL, separator, encodedPolicy, encodedSig, s.keyPairID) +} + // cfBase64Encode applies CloudFront's URL-safe base64 encoding. func cfBase64Encode(data []byte) string { encoded := base64.StdEncoding.EncodeToString(data) diff --git a/server/internal/handler/activity.go b/server/internal/handler/activity.go index 5430b78a..b73810eb 100644 --- a/server/internal/handler/activity.go +++ b/server/internal/handler/activity.go @@ -25,11 +25,12 @@ type TimelineEntry struct { Details json.RawMessage `json:"details,omitempty"` // Comment-only fields - Content *string `json:"content,omitempty"` - ParentID *string `json:"parent_id,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` - CommentType *string `json:"comment_type,omitempty"` - Reactions []ReactionResponse `json:"reactions,omitempty"` + Content *string `json:"content,omitempty"` + ParentID *string `json:"parent_id,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` + CommentType *string `json:"comment_type,omitempty"` + Reactions []ReactionResponse `json:"reactions,omitempty"` + Attachments []AttachmentResponse `json:"attachments,omitempty"` } // ListTimeline returns a merged, chronologically-sorted timeline of activities @@ -79,20 +80,22 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) { }) } - // Fetch reactions for all comments in one batch. + // Fetch reactions and attachments for all comments in one batch. commentIDs := make([]pgtype.UUID, len(comments)) for i, c := range comments { commentIDs[i] = c.ID } grouped := h.groupReactions(r, commentIDs) + groupedAtt := h.groupAttachments(r, commentIDs) for _, c := range comments { content := c.Content commentType := c.Type updatedAt := timestampToString(c.UpdatedAt) + cid := uuidToString(c.ID) timeline = append(timeline, TimelineEntry{ Type: "comment", - ID: uuidToString(c.ID), + ID: cid, ActorType: c.AuthorType, ActorID: uuidToString(c.AuthorID), Content: &content, @@ -100,7 +103,8 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) { ParentID: uuidToPtr(c.ParentID), CreatedAt: timestampToString(c.CreatedAt), UpdatedAt: &updatedAt, - Reactions: grouped[uuidToString(c.ID)], + Reactions: grouped[cid], + Attachments: groupedAtt[cid], }) } diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index a1b2c38c..f00f15c2 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -13,33 +13,38 @@ import ( ) type CommentResponse struct { - ID string `json:"id"` - IssueID string `json:"issue_id"` - AuthorType string `json:"author_type"` - AuthorID string `json:"author_id"` - Content string `json:"content"` - Type string `json:"type"` - ParentID *string `json:"parent_id"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Reactions []ReactionResponse `json:"reactions"` + ID string `json:"id"` + IssueID string `json:"issue_id"` + AuthorType string `json:"author_type"` + AuthorID string `json:"author_id"` + Content string `json:"content"` + Type string `json:"type"` + ParentID *string `json:"parent_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Reactions []ReactionResponse `json:"reactions"` + Attachments []AttachmentResponse `json:"attachments"` } -func commentToResponse(c db.Comment, reactions []ReactionResponse) CommentResponse { +func commentToResponse(c db.Comment, reactions []ReactionResponse, attachments []AttachmentResponse) CommentResponse { if reactions == nil { reactions = []ReactionResponse{} } + if attachments == nil { + attachments = []AttachmentResponse{} + } return CommentResponse{ - ID: uuidToString(c.ID), - IssueID: uuidToString(c.IssueID), - AuthorType: c.AuthorType, - AuthorID: uuidToString(c.AuthorID), - Content: c.Content, - Type: c.Type, - ParentID: uuidToPtr(c.ParentID), - CreatedAt: timestampToString(c.CreatedAt), - UpdatedAt: timestampToString(c.UpdatedAt), - Reactions: reactions, + ID: uuidToString(c.ID), + IssueID: uuidToString(c.IssueID), + AuthorType: c.AuthorType, + AuthorID: uuidToString(c.AuthorID), + Content: c.Content, + Type: c.Type, + ParentID: uuidToPtr(c.ParentID), + CreatedAt: timestampToString(c.CreatedAt), + UpdatedAt: timestampToString(c.UpdatedAt), + Reactions: reactions, + Attachments: attachments, } } @@ -64,10 +69,12 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) { commentIDs[i] = c.ID } grouped := h.groupReactions(r, commentIDs) + groupedAtt := h.groupAttachments(r, commentIDs) resp := make([]CommentResponse, len(comments)) for i, c := range comments { - resp[i] = commentToResponse(c, grouped[uuidToString(c.ID)]) + cid := uuidToString(c.ID) + resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid]) } writeJSON(w, http.StatusOK, resp) @@ -133,7 +140,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { return } - resp := commentToResponse(comment, nil) + 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{ "comment": resp, @@ -215,9 +222,11 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) { return } - // Fetch reactions for the updated comment. + // Fetch reactions and attachments for the updated comment. grouped := h.groupReactions(r, []pgtype.UUID{comment.ID}) - resp := commentToResponse(comment, grouped[uuidToString(comment.ID)]) + groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID}) + cid := uuidToString(comment.ID) + resp := commentToResponse(comment, grouped[cid], groupedAtt[cid]) slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...) h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp}) writeJSON(w, http.StatusOK, resp) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index c1afabc0..04c70649 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -9,8 +9,10 @@ import ( "net/http" "path" "strings" + "time" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" ) @@ -54,12 +56,13 @@ type AttachmentResponse struct { UploaderID string `json:"uploader_id"` Filename string `json:"filename"` URL string `json:"url"` + DownloadURL string `json:"download_url"` ContentType string `json:"content_type"` SizeBytes int64 `json:"size_bytes"` CreatedAt string `json:"created_at"` } -func attachmentToResponse(a db.Attachment) AttachmentResponse { +func (h *Handler) attachmentToResponse(a db.Attachment) AttachmentResponse { resp := AttachmentResponse{ ID: uuidToString(a.ID), WorkspaceID: uuidToString(a.WorkspaceID), @@ -67,10 +70,14 @@ func attachmentToResponse(a db.Attachment) AttachmentResponse { UploaderID: uuidToString(a.UploaderID), Filename: a.Filename, URL: a.Url, + DownloadURL: a.Url, ContentType: a.ContentType, SizeBytes: a.SizeBytes, CreatedAt: a.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), } + if h.CFSigner != nil { + resp.DownloadURL = h.CFSigner.SignedURL(a.Url, time.Now().Add(5*time.Minute)) + } if a.IssueID.Valid { s := uuidToString(a.IssueID) resp.IssueID = &s @@ -82,6 +89,23 @@ func attachmentToResponse(a db.Attachment) AttachmentResponse { return resp } +// groupAttachments loads attachments for multiple comments and groups them by comment ID. +func (h *Handler) groupAttachments(r *http.Request, commentIDs []pgtype.UUID) map[string][]AttachmentResponse { + if len(commentIDs) == 0 { + return nil + } + attachments, err := h.Queries.ListAttachmentsByCommentIDs(r.Context(), commentIDs) + if err != nil { + return nil + } + grouped := make(map[string][]AttachmentResponse, len(commentIDs)) + for _, a := range attachments { + cid := uuidToString(a.CommentID) + grouped[cid] = append(grouped[cid], h.attachmentToResponse(a)) + } + return grouped +} + // --------------------------------------------------------------------------- // UploadFile — POST /api/upload-file // --------------------------------------------------------------------------- @@ -181,7 +205,7 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { // S3 upload succeeded but DB record failed — still return the link // so the file is usable. Log the error for investigation. } else { - writeJSON(w, http.StatusOK, attachmentToResponse(att)) + writeJSON(w, http.StatusOK, h.attachmentToResponse(att)) return } } @@ -216,7 +240,7 @@ func (h *Handler) ListAttachments(w http.ResponseWriter, r *http.Request) { resp := make([]AttachmentResponse, len(attachments)) for i, a := range attachments { - resp[i] = attachmentToResponse(a) + resp[i] = h.attachmentToResponse(a) } writeJSON(w, http.StatusOK, resp) } diff --git a/server/pkg/db/generated/attachment.sql.go b/server/pkg/db/generated/attachment.sql.go index b653e2a9..858365ad 100644 --- a/server/pkg/db/generated/attachment.sql.go +++ b/server/pkg/db/generated/attachment.sql.go @@ -144,6 +144,44 @@ func (q *Queries) ListAttachmentsByComment(ctx context.Context, arg ListAttachme return items, nil } +const listAttachmentsByCommentIDs = `-- name: ListAttachmentsByCommentIDs :many +SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment +WHERE comment_id = ANY($1::uuid[]) +ORDER BY created_at ASC +` + +func (q *Queries) ListAttachmentsByCommentIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Attachment, error) { + rows, err := q.db.Query(ctx, listAttachmentsByCommentIDs, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Attachment{} + for rows.Next() { + var i Attachment + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.IssueID, + &i.CommentID, + &i.UploaderType, + &i.UploaderID, + &i.Filename, + &i.Url, + &i.ContentType, + &i.SizeBytes, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAttachmentsByIssue = `-- name: ListAttachmentsByIssue :many SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment WHERE issue_id = $1 AND workspace_id = $2 diff --git a/server/pkg/db/queries/attachment.sql b/server/pkg/db/queries/attachment.sql index 1505c2dd..6003ab88 100644 --- a/server/pkg/db/queries/attachment.sql +++ b/server/pkg/db/queries/attachment.sql @@ -17,5 +17,10 @@ ORDER BY created_at ASC; SELECT * FROM attachment WHERE id = $1 AND workspace_id = $2; +-- name: ListAttachmentsByCommentIDs :many +SELECT * FROM attachment +WHERE comment_id = ANY($1::uuid[]) +ORDER BY created_at ASC; + -- name: DeleteAttachment :exec DELETE FROM attachment WHERE id = $1 AND workspace_id = $2;