feat(upload): add attachment table for tracking uploaded files
- Add attachment table with workspace/issue/comment associations
- Upload handler creates attachment record when workspace context exists
- Add GET /api/issues/{id}/attachments and DELETE /api/attachments/{id}
- Frontend passes issueId context during uploads for tracking
- Add Attachment type, listAttachments, deleteAttachment to API client
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
423aa38888
commit
15f96468be
15 changed files with 478 additions and 25 deletions
|
|
@ -26,6 +26,7 @@ import type { TimelineEntry } from "@/shared/types";
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface CommentCardProps {
|
interface CommentCardProps {
|
||||||
|
issueId: string;
|
||||||
entry: TimelineEntry;
|
entry: TimelineEntry;
|
||||||
allReplies: Map<string, TimelineEntry[]>;
|
allReplies: Map<string, TimelineEntry[]>;
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
|
|
@ -165,6 +166,7 @@ function CommentRow({
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function CommentCard({
|
function CommentCard({
|
||||||
|
issueId,
|
||||||
entry,
|
entry,
|
||||||
allReplies,
|
allReplies,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
|
@ -213,6 +215,7 @@ function CommentCard({
|
||||||
{/* Reply input — always visible at bottom */}
|
{/* Reply input — always visible at bottom */}
|
||||||
<div className="border-t border-border/50 px-4 py-2.5">
|
<div className="border-t border-border/50 px-4 py-2.5">
|
||||||
<ReplyInput
|
<ReplyInput
|
||||||
|
issueId={issueId}
|
||||||
placeholder="Leave a reply..."
|
placeholder="Leave a reply..."
|
||||||
size="sm"
|
size="sm"
|
||||||
avatarType="member"
|
avatarType="member"
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,11 @@ import { useFileUpload } from "@/hooks/use-file-upload";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface CommentInputProps {
|
interface CommentInputProps {
|
||||||
|
issueId: string;
|
||||||
onSubmit: (content: string) => Promise<void>;
|
onSubmit: (content: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommentInput({ onSubmit }: CommentInputProps) {
|
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||||
const editorRef = useRef<RichTextEditorRef>(null);
|
const editorRef = useRef<RichTextEditorRef>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isEmpty, setIsEmpty] = useState(true);
|
const [isEmpty, setIsEmpty] = useState(true);
|
||||||
|
|
@ -20,7 +21,7 @@ function CommentInput({ onSubmit }: CommentInputProps) {
|
||||||
|
|
||||||
const handleUpload = async (file: File) => {
|
const handleUpload = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
const result = await upload(file);
|
const result = await upload(file, { issueId });
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Upload failed");
|
toast.error(err instanceof Error ? err.message : "Upload failed");
|
||||||
|
|
|
||||||
|
|
@ -254,13 +254,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
const handleDescriptionUpload = useCallback(
|
const handleDescriptionUpload = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
try {
|
try {
|
||||||
return await uploadFile(file);
|
return await uploadFile(file, { issueId: id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Upload failed");
|
toast.error(err instanceof Error ? err.message : "Upload failed");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[uploadFile],
|
[uploadFile, id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
|
|
@ -756,6 +756,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
return (
|
return (
|
||||||
<CommentCard
|
<CommentCard
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
|
issueId={id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
allReplies={repliesByParent}
|
allReplies={repliesByParent}
|
||||||
currentUserId={user?.id}
|
currentUserId={user?.id}
|
||||||
|
|
@ -818,7 +819,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||||
|
|
||||||
{/* Bottom comment input — no avatar, full width */}
|
{/* Bottom comment input — no avatar, full width */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<CommentInput onSubmit={submitComment} />
|
<CommentInput issueId={id} onSubmit={submitComment} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { toast } from "sonner";
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface ReplyInputProps {
|
interface ReplyInputProps {
|
||||||
|
issueId: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
avatarType: string;
|
avatarType: string;
|
||||||
avatarId: string;
|
avatarId: string;
|
||||||
|
|
@ -25,6 +26,7 @@ interface ReplyInputProps {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function ReplyInput({
|
function ReplyInput({
|
||||||
|
issueId,
|
||||||
placeholder = "Leave a reply...",
|
placeholder = "Leave a reply...",
|
||||||
avatarType,
|
avatarType,
|
||||||
avatarId,
|
avatarId,
|
||||||
|
|
@ -39,7 +41,7 @@ function ReplyInput({
|
||||||
|
|
||||||
const handleUpload = async (file: File) => {
|
const handleUpload = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
const result = await upload(file);
|
const result = await upload(file, { issueId });
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Upload failed");
|
toast.error(err instanceof Error ? err.message : "Upload failed");
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { api } from "@/shared/api";
|
import { api } from "@/shared/api";
|
||||||
|
import type { Attachment } from "@/shared/types";
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
|
|
@ -32,11 +33,16 @@ export interface UploadResult {
|
||||||
link: string;
|
link: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadContext {
|
||||||
|
issueId?: string;
|
||||||
|
commentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function useFileUpload() {
|
export function useFileUpload() {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
const upload = useCallback(
|
const upload = useCallback(
|
||||||
async (file: File): Promise<UploadResult | null> => {
|
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
throw new Error("File exceeds 10 MB limit");
|
throw new Error("File exceeds 10 MB limit");
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +52,11 @@ export function useFileUpload() {
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
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 {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import type {
|
||||||
RuntimePing,
|
RuntimePing,
|
||||||
TimelineEntry,
|
TimelineEntry,
|
||||||
TaskMessagePayload,
|
TaskMessagePayload,
|
||||||
|
Attachment,
|
||||||
} from "@/shared/types";
|
} from "@/shared/types";
|
||||||
import { type Logger, noopLogger } from "@/shared/logger";
|
import { type Logger, noopLogger } from "@/shared/logger";
|
||||||
|
|
||||||
|
|
@ -520,10 +521,12 @@ export class ApiClient {
|
||||||
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
|
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// File Upload
|
// File Upload & Attachments
|
||||||
async uploadFile(file: File): Promise<{ filename: string; link: string }> {
|
async uploadFile(file: File, opts?: { issueId?: string; commentId?: string }): Promise<Attachment> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
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<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
||||||
|
|
@ -556,6 +559,14 @@ export class ApiClient {
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json() as Promise<{ filename: string; link: string }>;
|
return res.json() as Promise<Attachment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAttachments(issueId: string): Promise<Attachment[]> {
|
||||||
|
return this.fetch(`/api/issues/${issueId}/attachments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAttachment(id: string): Promise<void> {
|
||||||
|
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
apps/web/shared/types/attachment.ts
Normal file
13
apps/web/shared/types/attachment.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -30,3 +30,4 @@ export type { IssueSubscriber } from "./subscriber";
|
||||||
export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon";
|
export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon";
|
||||||
export type * from "./events";
|
export type * from "./events";
|
||||||
export type * from "./api";
|
export type * from "./api";
|
||||||
|
export type { Attachment } from "./attachment";
|
||||||
|
|
|
||||||
|
|
@ -175,9 +175,13 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||||
r.Get("/task-runs", h.ListTasksByIssue)
|
r.Get("/task-runs", h.ListTasksByIssue)
|
||||||
r.Post("/reactions", h.AddIssueReaction)
|
r.Post("/reactions", h.AddIssueReaction)
|
||||||
r.Delete("/reactions", h.RemoveIssueReaction)
|
r.Delete("/reactions", h.RemoveIssueReaction)
|
||||||
|
r.Get("/attachments", h.ListAttachments)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
r.Delete("/api/attachments/{id}", h.DeleteAttachment)
|
||||||
|
|
||||||
// Comments
|
// Comments
|
||||||
r.Route("/api/comments/{commentId}", func(r chi.Router) {
|
r.Route("/api/comments/{commentId}", func(r chi.Router) {
|
||||||
r.Put("/", h.UpdateComment)
|
r.Put("/", h.UpdateComment)
|
||||||
|
|
|
||||||
|
|
@ -9,26 +9,29 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxUploadSize = 10 << 20 // 10 MB
|
const maxUploadSize = 10 << 20 // 10 MB
|
||||||
|
|
||||||
// Allowed MIME type prefixes and exact types for uploads.
|
// Allowed MIME type prefixes and exact types for uploads.
|
||||||
var allowedContentTypes = map[string]bool{
|
var allowedContentTypes = map[string]bool{
|
||||||
"image/png": true,
|
"image/png": true,
|
||||||
"image/jpeg": true,
|
"image/jpeg": true,
|
||||||
"image/gif": true,
|
"image/gif": true,
|
||||||
"image/webp": true,
|
"image/webp": true,
|
||||||
"image/svg+xml": true,
|
"image/svg+xml": true,
|
||||||
"application/pdf": true,
|
"application/pdf": true,
|
||||||
"text/plain": true,
|
"text/plain": true,
|
||||||
"text/csv": true,
|
"text/csv": true,
|
||||||
"application/json": true,
|
"application/json": true,
|
||||||
"video/mp4": true,
|
"video/mp4": true,
|
||||||
"video/webm": true,
|
"video/webm": true,
|
||||||
"audio/mpeg": true,
|
"audio/mpeg": true,
|
||||||
"audio/wav": true,
|
"audio/wav": true,
|
||||||
"application/zip": true,
|
"application/zip": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func isContentTypeAllowed(ct string) bool {
|
func isContentTypeAllowed(ct string) bool {
|
||||||
|
|
@ -38,12 +41,64 @@ func isContentTypeAllowed(ct string) bool {
|
||||||
return allowedContentTypes[ct]
|
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) {
|
func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.Storage == nil {
|
if h.Storage == nil {
|
||||||
writeError(w, http.StatusServiceUnavailable, "file upload not configured")
|
writeError(w, http.StatusServiceUnavailable, "file upload not configured")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userID, ok := requireUserID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceID := resolveWorkspaceID(r)
|
||||||
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
||||||
|
|
||||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||||
|
|
@ -98,8 +153,119 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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{
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
"filename": header.Filename,
|
"filename": header.Filename,
|
||||||
"link": link,
|
"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)
|
||||||
|
}
|
||||||
|
|
|
||||||
1
server/migrations/029_attachment.down.sql
Normal file
1
server/migrations/029_attachment.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS attachment;
|
||||||
17
server/migrations/029_attachment.up.sql
Normal file
17
server/migrations/029_attachment.up.sql
Normal file
|
|
@ -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);
|
||||||
188
server/pkg/db/generated/attachment.sql.go
Normal file
188
server/pkg/db/generated/attachment.sql.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -79,6 +79,20 @@ type AgentTaskQueue struct {
|
||||||
TriggerCommentID pgtype.UUID `json:"trigger_comment_id"`
|
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 {
|
type Comment struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
IssueID pgtype.UUID `json:"issue_id"`
|
IssueID pgtype.UUID `json:"issue_id"`
|
||||||
|
|
|
||||||
21
server/pkg/db/queries/attachment.sql
Normal file
21
server/pkg/db/queries/attachment.sql
Normal file
|
|
@ -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;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue