diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index eef902d4..eb1261a4 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -26,6 +26,7 @@ import type { TimelineEntry } from "@/shared/types"; // --------------------------------------------------------------------------- interface CommentCardProps { + issueId: string; entry: TimelineEntry; allReplies: Map; currentUserId?: string; @@ -165,6 +166,7 @@ function CommentRow({ // --------------------------------------------------------------------------- function CommentCard({ + issueId, entry, allReplies, currentUserId, @@ -213,6 +215,7 @@ function CommentCard({ {/* Reply input — always visible at bottom */}
Promise; } -function CommentInput({ onSubmit }: CommentInputProps) { +function CommentInput({ issueId, onSubmit }: CommentInputProps) { const editorRef = useRef(null); const fileInputRef = useRef(null); const [isEmpty, setIsEmpty] = useState(true); @@ -20,7 +21,7 @@ function CommentInput({ onSubmit }: CommentInputProps) { const handleUpload = async (file: File) => { try { - const result = await upload(file); + const result = await upload(file, { issueId }); return result; } catch (err) { toast.error(err instanceof Error ? err.message : "Upload failed"); diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index f3633e8d..bc0d2a0a 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -254,13 +254,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const handleDescriptionUpload = useCallback( async (file: File) => { try { - return await uploadFile(file); + return await uploadFile(file, { issueId: id }); } catch (err) { toast.error(err instanceof Error ? err.message : "Upload failed"); return null; } }, - [uploadFile], + [uploadFile, id], ); const handleDelete = async () => { @@ -756,6 +756,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo return ( - +
diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index db3cd80f..f8fb097b 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -13,6 +13,7 @@ import { toast } from "sonner"; // --------------------------------------------------------------------------- interface ReplyInputProps { + issueId: string; placeholder?: string; avatarType: string; avatarId: string; @@ -25,6 +26,7 @@ interface ReplyInputProps { // --------------------------------------------------------------------------- function ReplyInput({ + issueId, placeholder = "Leave a reply...", avatarType, avatarId, @@ -39,7 +41,7 @@ function ReplyInput({ const handleUpload = async (file: File) => { try { - const result = await upload(file); + const result = await upload(file, { issueId }); return result; } catch (err) { toast.error(err instanceof Error ? err.message : "Upload failed"); diff --git a/apps/web/hooks/use-file-upload.ts b/apps/web/hooks/use-file-upload.ts index 812e6238..bd3071fb 100644 --- a/apps/web/hooks/use-file-upload.ts +++ b/apps/web/hooks/use-file-upload.ts @@ -2,6 +2,7 @@ import { useState, useCallback } from "react"; import { api } from "@/shared/api"; +import type { Attachment } from "@/shared/types"; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB @@ -32,11 +33,16 @@ export interface UploadResult { link: string; } +export interface UploadContext { + issueId?: string; + commentId?: string; +} + export function useFileUpload() { const [uploading, setUploading] = useState(false); const upload = useCallback( - async (file: File): Promise => { + async (file: File, ctx?: UploadContext): Promise => { if (file.size > MAX_FILE_SIZE) { throw new Error("File exceeds 10 MB limit"); } @@ -46,7 +52,11 @@ export function useFileUpload() { setUploading(true); try { - return await api.uploadFile(file); + const att: Attachment = await api.uploadFile(file, { + issueId: ctx?.issueId, + commentId: ctx?.commentId, + }); + return { filename: att.filename, link: att.url }; } finally { setUploading(false); } diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 994cc666..172efc4b 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -35,6 +35,7 @@ import type { RuntimePing, TimelineEntry, TaskMessagePayload, + Attachment, } from "@/shared/types"; import { type Logger, noopLogger } from "@/shared/logger"; @@ -520,10 +521,12 @@ export class ApiClient { await this.fetch(`/api/tokens/${id}`, { method: "DELETE" }); } - // File Upload - async uploadFile(file: File): Promise<{ filename: string; link: string }> { + // File Upload & Attachments + async uploadFile(file: File, opts?: { issueId?: string; commentId?: string }): Promise { const formData = new FormData(); formData.append("file", file); + if (opts?.issueId) formData.append("issue_id", opts.issueId); + if (opts?.commentId) formData.append("comment_id", opts.commentId); const headers: Record = {}; if (this.token) headers["Authorization"] = `Bearer ${this.token}`; @@ -556,6 +559,14 @@ export class ApiClient { throw new Error(message); } - return res.json() as Promise<{ filename: string; link: string }>; + return res.json() as Promise; + } + + async listAttachments(issueId: string): Promise { + return this.fetch(`/api/issues/${issueId}/attachments`); + } + + async deleteAttachment(id: string): Promise { + await this.fetch(`/api/attachments/${id}`, { method: "DELETE" }); } } diff --git a/apps/web/shared/types/attachment.ts b/apps/web/shared/types/attachment.ts new file mode 100644 index 00000000..c69ccc44 --- /dev/null +++ b/apps/web/shared/types/attachment.ts @@ -0,0 +1,13 @@ +export interface Attachment { + id: string; + workspace_id: string; + issue_id: string | null; + comment_id: string | null; + uploader_type: string; + uploader_id: string; + filename: string; + url: string; + content_type: string; + size_bytes: number; + created_at: string; +} diff --git a/apps/web/shared/types/index.ts b/apps/web/shared/types/index.ts index 5ef60118..4c105ff5 100644 --- a/apps/web/shared/types/index.ts +++ b/apps/web/shared/types/index.ts @@ -30,3 +30,4 @@ export type { IssueSubscriber } from "./subscriber"; export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon"; export type * from "./events"; export type * from "./api"; +export type { Attachment } from "./attachment"; diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index e65b6b9a..9b75fd5a 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -175,9 +175,13 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Get("/task-runs", h.ListTasksByIssue) r.Post("/reactions", h.AddIssueReaction) r.Delete("/reactions", h.RemoveIssueReaction) + r.Get("/attachments", h.ListAttachments) }) }) + // Attachments + r.Delete("/api/attachments/{id}", h.DeleteAttachment) + // Comments r.Route("/api/comments/{commentId}", func(r chi.Router) { r.Put("/", h.UpdateComment) diff --git a/server/internal/handler/file.go b/server/internal/handler/file.go index 230c0336..c1afabc0 100644 --- a/server/internal/handler/file.go +++ b/server/internal/handler/file.go @@ -9,26 +9,29 @@ import ( "net/http" "path" "strings" + + "github.com/go-chi/chi/v5" + db "github.com/multica-ai/multica/server/pkg/db/generated" ) const maxUploadSize = 10 << 20 // 10 MB // Allowed MIME type prefixes and exact types for uploads. var allowedContentTypes = map[string]bool{ - "image/png": true, - "image/jpeg": true, - "image/gif": true, - "image/webp": true, - "image/svg+xml": true, - "application/pdf": true, - "text/plain": true, - "text/csv": true, - "application/json": true, - "video/mp4": true, - "video/webm": true, - "audio/mpeg": true, - "audio/wav": true, - "application/zip": true, + "image/png": true, + "image/jpeg": true, + "image/gif": true, + "image/webp": true, + "image/svg+xml": true, + "application/pdf": true, + "text/plain": true, + "text/csv": true, + "application/json": true, + "video/mp4": true, + "video/webm": true, + "audio/mpeg": true, + "audio/wav": true, + "application/zip": true, } func isContentTypeAllowed(ct string) bool { @@ -38,12 +41,64 @@ func isContentTypeAllowed(ct string) bool { return allowedContentTypes[ct] } +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +type AttachmentResponse struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + IssueID *string `json:"issue_id"` + CommentID *string `json:"comment_id"` + UploaderType string `json:"uploader_type"` + UploaderID string `json:"uploader_id"` + Filename string `json:"filename"` + URL string `json:"url"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt string `json:"created_at"` +} + +func attachmentToResponse(a db.Attachment) AttachmentResponse { + resp := AttachmentResponse{ + ID: uuidToString(a.ID), + WorkspaceID: uuidToString(a.WorkspaceID), + UploaderType: a.UploaderType, + UploaderID: uuidToString(a.UploaderID), + Filename: a.Filename, + URL: a.Url, + ContentType: a.ContentType, + SizeBytes: a.SizeBytes, + CreatedAt: a.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"), + } + if a.IssueID.Valid { + s := uuidToString(a.IssueID) + resp.IssueID = &s + } + if a.CommentID.Valid { + s := uuidToString(a.CommentID) + resp.CommentID = &s + } + return resp +} + +// --------------------------------------------------------------------------- +// UploadFile — POST /api/upload-file +// --------------------------------------------------------------------------- + func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { if h.Storage == nil { writeError(w, http.StatusServiceUnavailable, "file upload not configured") return } + userID, ok := requireUserID(w, r) + if !ok { + return + } + + workspaceID := resolveWorkspaceID(r) + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) if err := r.ParseMultipartForm(maxUploadSize); err != nil { @@ -98,8 +153,119 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { return } + // If workspace context is available, create an attachment record. + if workspaceID != "" { + uploaderType, uploaderID := h.resolveActor(r, userID, workspaceID) + + params := db.CreateAttachmentParams{ + WorkspaceID: parseUUID(workspaceID), + UploaderType: uploaderType, + UploaderID: parseUUID(uploaderID), + Filename: header.Filename, + Url: link, + ContentType: contentType, + SizeBytes: int64(len(data)), + } + + // Optional issue_id / comment_id from form fields + if issueID := r.FormValue("issue_id"); issueID != "" { + params.IssueID = parseUUID(issueID) + } + if commentID := r.FormValue("comment_id"); commentID != "" { + params.CommentID = parseUUID(commentID) + } + + att, err := h.Queries.CreateAttachment(r.Context(), params) + if err != nil { + slog.Error("failed to create attachment record", "error", err) + // 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)) + return + } + } + + // Fallback response (no workspace context, e.g. avatar upload) writeJSON(w, http.StatusOK, map[string]string{ "filename": header.Filename, "link": link, }) } + +// --------------------------------------------------------------------------- +// ListAttachments — GET /api/issues/{id}/attachments +// --------------------------------------------------------------------------- + +func (h *Handler) ListAttachments(w http.ResponseWriter, r *http.Request) { + issueID := chi.URLParam(r, "id") + issue, ok := h.loadIssueForUser(w, r, issueID) + if !ok { + return + } + + attachments, err := h.Queries.ListAttachmentsByIssue(r.Context(), db.ListAttachmentsByIssueParams{ + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + }) + if err != nil { + slog.Error("failed to list attachments", "error", err) + writeError(w, http.StatusInternalServerError, "failed to list attachments") + return + } + + resp := make([]AttachmentResponse, len(attachments)) + for i, a := range attachments { + resp[i] = attachmentToResponse(a) + } + writeJSON(w, http.StatusOK, resp) +} + +// --------------------------------------------------------------------------- +// DeleteAttachment — DELETE /api/attachments/{id} +// --------------------------------------------------------------------------- + +func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) { + attachmentID := chi.URLParam(r, "id") + workspaceID := resolveWorkspaceID(r) + if workspaceID == "" { + writeError(w, http.StatusBadRequest, "workspace_id is required") + return + } + + userID, ok := requireUserID(w, r) + if !ok { + return + } + + att, err := h.Queries.GetAttachment(r.Context(), db.GetAttachmentParams{ + ID: parseUUID(attachmentID), + WorkspaceID: parseUUID(workspaceID), + }) + if err != nil { + writeError(w, http.StatusNotFound, "attachment not found") + return + } + + // Only the uploader (or workspace admin) can delete + uploaderID := uuidToString(att.UploaderID) + isUploader := att.UploaderType == "member" && uploaderID == userID + member, hasMember := ctxMember(r.Context()) + isAdmin := hasMember && (member.Role == "admin" || member.Role == "owner") + + if !isUploader && !isAdmin { + writeError(w, http.StatusForbidden, "not authorized to delete this attachment") + return + } + + if err := h.Queries.DeleteAttachment(r.Context(), db.DeleteAttachmentParams{ + ID: att.ID, + WorkspaceID: att.WorkspaceID, + }); err != nil { + slog.Error("failed to delete attachment", "error", err) + writeError(w, http.StatusInternalServerError, "failed to delete attachment") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/migrations/029_attachment.down.sql b/server/migrations/029_attachment.down.sql new file mode 100644 index 00000000..4e5f6d4f --- /dev/null +++ b/server/migrations/029_attachment.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS attachment; diff --git a/server/migrations/029_attachment.up.sql b/server/migrations/029_attachment.up.sql new file mode 100644 index 00000000..225c373a --- /dev/null +++ b/server/migrations/029_attachment.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE attachment ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + issue_id UUID REFERENCES issue(id) ON DELETE CASCADE, + comment_id UUID REFERENCES comment(id) ON DELETE CASCADE, + uploader_type TEXT NOT NULL CHECK (uploader_type IN ('member', 'agent')), + uploader_id UUID NOT NULL, + filename TEXT NOT NULL, + url TEXT NOT NULL, + content_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_attachment_issue ON attachment(issue_id) WHERE issue_id IS NOT NULL; +CREATE INDEX idx_attachment_comment ON attachment(comment_id) WHERE comment_id IS NOT NULL; +CREATE INDEX idx_attachment_workspace ON attachment(workspace_id); diff --git a/server/pkg/db/generated/attachment.sql.go b/server/pkg/db/generated/attachment.sql.go new file mode 100644 index 00000000..b653e2a9 --- /dev/null +++ b/server/pkg/db/generated/attachment.sql.go @@ -0,0 +1,188 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: attachment.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +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) +RETURNING id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at +` + +type CreateAttachmentParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + UploaderType string `json:"uploader_type"` + UploaderID pgtype.UUID `json:"uploader_id"` + Filename string `json:"filename"` + Url string `json:"url"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + IssueID pgtype.UUID `json:"issue_id"` + CommentID pgtype.UUID `json:"comment_id"` +} + +func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (Attachment, error) { + row := q.db.QueryRow(ctx, createAttachment, + arg.WorkspaceID, + arg.UploaderType, + arg.UploaderID, + arg.Filename, + arg.Url, + arg.ContentType, + arg.SizeBytes, + arg.IssueID, + arg.CommentID, + ) + var i Attachment + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.IssueID, + &i.CommentID, + &i.UploaderType, + &i.UploaderID, + &i.Filename, + &i.Url, + &i.ContentType, + &i.SizeBytes, + &i.CreatedAt, + ) + return i, err +} + +const deleteAttachment = `-- name: DeleteAttachment :exec +DELETE FROM attachment WHERE id = $1 AND workspace_id = $2 +` + +type DeleteAttachmentParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) DeleteAttachment(ctx context.Context, arg DeleteAttachmentParams) error { + _, err := q.db.Exec(ctx, deleteAttachment, arg.ID, arg.WorkspaceID) + return err +} + +const getAttachment = `-- name: GetAttachment :one +SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at FROM attachment +WHERE id = $1 AND workspace_id = $2 +` + +type GetAttachmentParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) GetAttachment(ctx context.Context, arg GetAttachmentParams) (Attachment, error) { + row := q.db.QueryRow(ctx, getAttachment, arg.ID, arg.WorkspaceID) + var i Attachment + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.IssueID, + &i.CommentID, + &i.UploaderType, + &i.UploaderID, + &i.Filename, + &i.Url, + &i.ContentType, + &i.SizeBytes, + &i.CreatedAt, + ) + return i, err +} + +const listAttachmentsByComment = `-- name: ListAttachmentsByComment :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 = $1 AND workspace_id = $2 +ORDER BY created_at ASC +` + +type ListAttachmentsByCommentParams struct { + CommentID pgtype.UUID `json:"comment_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) ListAttachmentsByComment(ctx context.Context, arg ListAttachmentsByCommentParams) ([]Attachment, error) { + rows, err := q.db.Query(ctx, listAttachmentsByComment, arg.CommentID, arg.WorkspaceID) + 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 +ORDER BY created_at ASC +` + +type ListAttachmentsByIssueParams struct { + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) ListAttachmentsByIssue(ctx context.Context, arg ListAttachmentsByIssueParams) ([]Attachment, error) { + rows, err := q.db.Query(ctx, listAttachmentsByIssue, arg.IssueID, arg.WorkspaceID) + 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 +} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 9547212e..9ba8f116 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -79,6 +79,20 @@ type AgentTaskQueue struct { TriggerCommentID pgtype.UUID `json:"trigger_comment_id"` } +type Attachment struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + IssueID pgtype.UUID `json:"issue_id"` + CommentID pgtype.UUID `json:"comment_id"` + UploaderType string `json:"uploader_type"` + UploaderID pgtype.UUID `json:"uploader_id"` + Filename string `json:"filename"` + Url string `json:"url"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Comment struct { ID pgtype.UUID `json:"id"` IssueID pgtype.UUID `json:"issue_id"` diff --git a/server/pkg/db/queries/attachment.sql b/server/pkg/db/queries/attachment.sql new file mode 100644 index 00000000..1505c2dd --- /dev/null +++ b/server/pkg/db/queries/attachment.sql @@ -0,0 +1,21 @@ +-- 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) +RETURNING *; + +-- name: ListAttachmentsByIssue :many +SELECT * FROM attachment +WHERE issue_id = $1 AND workspace_id = $2 +ORDER BY created_at ASC; + +-- name: ListAttachmentsByComment :many +SELECT * FROM attachment +WHERE comment_id = $1 AND workspace_id = $2 +ORDER BY created_at ASC; + +-- name: GetAttachment :one +SELECT * FROM attachment +WHERE id = $1 AND workspace_id = $2; + +-- name: DeleteAttachment :exec +DELETE FROM attachment WHERE id = $1 AND workspace_id = $2;