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 {
|
||||
issueId: string;
|
||||
entry: TimelineEntry;
|
||||
allReplies: Map<string, TimelineEntry[]>;
|
||||
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 */}
|
||||
<div className="border-t border-border/50 px-4 py-2.5">
|
||||
<ReplyInput
|
||||
issueId={issueId}
|
||||
placeholder="Leave a reply..."
|
||||
size="sm"
|
||||
avatarType="member"
|
||||
|
|
|
|||
|
|
@ -8,10 +8,11 @@ import { useFileUpload } from "@/hooks/use-file-upload";
|
|||
import { toast } from "sonner";
|
||||
|
||||
interface CommentInputProps {
|
||||
issueId: string;
|
||||
onSubmit: (content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function CommentInput({ onSubmit }: CommentInputProps) {
|
||||
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const editorRef = useRef<RichTextEditorRef>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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");
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<CommentCard
|
||||
key={entry.id}
|
||||
issueId={id}
|
||||
entry={entry}
|
||||
allReplies={repliesByParent}
|
||||
currentUserId={user?.id}
|
||||
|
|
@ -818,7 +819,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
|||
|
||||
{/* Bottom comment input — no avatar, full width */}
|
||||
<div className="mt-4">
|
||||
<CommentInput onSubmit={submitComment} />
|
||||
<CommentInput issueId={id} onSubmit={submitComment} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<UploadResult | null> => {
|
||||
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Attachment> {
|
||||
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<string, string> = {};
|
||||
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<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 * from "./events";
|
||||
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.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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
|
|
|
|||
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