feat(upload): signed URLs for CLI + eager load attachments on comments
- Add CloudFrontSigner.SignedURL() for generating per-resource signed URLs - Attachment responses include download_url (5-min signed URL for CLI) - Eager load attachments on comments and timeline (same pattern as reactions) - Add ListAttachmentsByCommentIDs query for batch loading - Update Comment and TimelineEntry types with attachments field Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
15f96468be
commit
f5353c6691
10 changed files with 144 additions and 36 deletions
|
|
@ -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],
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue