mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 22:59:04 +02:00
* feat(chat): support images/files in agent chat replies (MUL-4287) Agents can now attach images/files to their chat replies, matching how comment attachments already work. The write-side gap was that the assistant chat_message is synthesized server-side from the completion callback's text output and never bound any attachments. Backend: - migration 150: nullable attachment.task_id (+ partial index), the transient handle that ties an agent's in-run upload to the reply it produces. - POST /api/upload-file accepts task_id: gated to the task's own agent, in this workspace, on a chat task; tags the row with task_id + chat_session_id. - CompleteTask (chat branch) binds the task's still-unclaimed attachments to the assistant message via BindChatAttachmentsToMessage (rejects rows already owned by an issue/comment/chat_message). An empty-output reply that produced files still creates a message so the images have an owner. FailTask binds nothing. CLI: - `multica attachment upload <path>` uploads a file for the current chat task (task from MULTICA_TASK_ID or --task) and prints id / markdown_url / a ready-to-paste markdown snippet. Prompt: - web/mobile chat prompt tells the agent how to attach a file to its reply. Mobile: - chat:done handler now always invalidates the messages list so attachments (absent from the event payload) refetch; mirrors web's self-heal. - chat bubbles render standalone attachment cards via the existing CommentAttachmentList (dedup vs inline references), matching web. Web/desktop needed no change — they already render message.attachments inline and via AttachmentList, and self-heal on chat:done. Tests: upload permission/isolation, bind-on-complete, empty-output+attachments, FailTask no-bind, null task_id untouched, already-owned not stolen, CLI output contract, mobile refetch-on-done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): address review blockers on chat reply attachments (MUL-4287) Two final-review blockers on PR #5164: 1. Mobile inline dedup only checked raw `url`, so an attachment referenced inline via `markdown_url` (exactly what the CLI snippet emits) rendered twice — once inline, once as a standalone card. Reuse the core `contentReferencesAttachment` helper so dedup covers every real reference form (stable /api/attachments/<id>/download path, url, download_url, markdown_url), matching web's AttachmentList. Extracted the filter into a pure `lib/attachment-dedup.ts` so it is unit-testable, and added a regression test covering `content` containing `attachment.markdown_url` (plus the other URL forms and same-identity sibling dedup). 2. CLI `attachment upload` emitted `![...]` image markdown for every file, producing a broken-image snippet for non-images. Emit image markdown only for image/* content types and a plain link otherwise, with a CLI contract test for both. Approved scope otherwise unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): renumber attachment_task_id migration 150 -> 157 after main merge (MUL-4287) Merged latest main; main renumbered its migrations and now occupies 150-156, so 150_attachment_task_id collided with 150_agent_task_coalesced_comments and would fail TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Renamed to the next unique prefix (157). No content change; migrate up applies cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): render agent-produced files as attachment cards, not raw links The chat upload command handed the agent a bare `[name](url)` markdown snippet. Pasted mid-sentence it renders as a plain text link (not a card), and the referenced URL hides the auto-bound standalone attachment — so a file the agent produced could end up showing as nothing. Return the block-level `!file[name](url)` card syntax instead (images keep `` inline), and markdown-escape the filename so names with `[`/`]` don't truncate the label. The prompt and CLI help now state the file auto-attaches below the reply and the snippet is optional, only for placement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): soften message-list scroll fade (32px → 16px) The 32px edge fade washed out full-bleed content (HTML / image previews) at the list edges. Halve the fade distance so it barely grazes previews while still hinting at more content above/below. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): renumber attachment_task_id migration 157 -> 158 main landed 157_agent_task_delivered_comments while this branch was open, colliding on prefix 157 and failing TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Bump this PR's migration to the next free prefix (158). Rename only; the migration body (nullable attachment.task_id + partial index) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): pin attachment upload to the token's task; build index concurrently Two code-review findings on the chat-attachment path (MUL-4287): - Isolation/privacy: POST /api/upload-file only checked the form task_id belonged to the caller's agent, not that it matched the task-scoped token's authoritative X-Task-ID. A run authorized for task A could tag an attachment onto task B (another chat task of the same agent, possibly another user's session), binding it into that reply on completion. Require the form task_id to equal the server-set X-Task-ID; add a same-agent/other-task 403 regression. - Migration: split the task_id lookup index into its own migration (159) built with CREATE INDEX CONCURRENTLY (repo convention) — it cannot share a multi-command file with the ADD COLUMN in 158. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): enforce task-token source on attachment upload; drop transient task_id FK (MUL-4287) Addresses the two remaining Preflight BLOCKERs on PR #5164. Security (file.go): the task_id upload path compared the form task_id to X-Task-ID but did not require X-Actor-Source=task_token. A normal JWT/mul_ PAT leaves that header empty and the middleware does NOT strip a client-forged X-Task-ID; resolveActor's fallback accepts a valid X-Agent-ID+X-Task-ID pair. So a member who learned a task ID could forge both and inject an attachment onto another chat task's assistant reply (cross-session/privacy leak). Now the branch requires X-Actor-Source=task_token first (mirrors chat_history.go's load-bearing boundary), then pins to the middleware-injected X-Task-ID. Tests now go through the real task-token headers and add a forged-JWT-403 regression. Migration (158): task_id is a transient binding handle (written once at upload against an already-validated task, read only during that task's own completion; durable owner is chat_message_id). There is no app-layer path that hard-deletes agent_task_queue rows, and orphan uploads are already reaped by attachment.chat_session_id's ON DELETE CASCADE — so an FK here would only add a cascade dependency the app never relies on plus write overhead on the hot attachment table. Drop the FK; task_id is now a plain UUID column. Added a regression test that an unbound task-tagged upload is reaped on chat_session delete. Index (159, CONCURRENTLY) unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(mobile): align !file card preprocess with web parser + CLI escaped labels (MUL-4287) Howard final-review blocker: mobile's `!file[...]` preprocess didn't keep up with the CLI's file-card output, so agent-produced non-image files rendered nowhere on mobile. - `FILE_LINE_RE` used `[^\]]+` for the label, so the CLI's escaped-bracket output `!file[a\]b.pdf](url)` (cmd_attachment.go escapeMarkdownLabel) never matched — the line stayed literal AND `standaloneAttachments` still hid the fallback card (the URL is in `content`), so the file showed nowhere. - Align the matcher with web's `packages/ui/markdown/file-cards.ts`: label allows backslash-escaped metacharacters (ReDoS-safe class), and the URL is restricted to the same allowlist (site-relative /uploads + /api/attachments/ <UUID>/download, plus absolute http(s)); disallowed schemes stay plain text. - Unescape the label to the real filename, then re-escape only the chars that would break a markdown LINK label (mobile emits `[📎 name](url)`, re-parsed by the renderer — unlike web's HTML data-filename), so a raw `]` never truncates the link text. No dedup change: once the inline `!file` renders, hiding the standalone card is correct. Added focused unit tests covering the escaped-label case, parens/ backslash unescape, the site-relative URL form, and disallowed-scheme rejection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
665 lines
18 KiB
Go
665 lines
18 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: attachment.sql
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const bindChatAttachmentsToMessage = `-- name: BindChatAttachmentsToMessage :many
|
|
UPDATE attachment
|
|
SET chat_message_id = $1
|
|
WHERE workspace_id = $2
|
|
AND task_id = $3
|
|
AND issue_id IS NULL
|
|
AND comment_id IS NULL
|
|
AND chat_message_id IS NULL
|
|
RETURNING id
|
|
`
|
|
|
|
type BindChatAttachmentsToMessageParams struct {
|
|
ChatMessageID pgtype.UUID `json:"chat_message_id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
TaskID pgtype.UUID `json:"task_id"`
|
|
}
|
|
|
|
// Bind a chat agent's task-scoped attachments to the assistant reply it just
|
|
// produced. Only rows still unclaimed by any owner (issue/comment/chat_message)
|
|
// are eligible, so an attachment already linked elsewhere is never stolen.
|
|
// Returns the bound ids for logging.
|
|
func (q *Queries) BindChatAttachmentsToMessage(ctx context.Context, arg BindChatAttachmentsToMessageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, bindChatAttachmentsToMessage, arg.ChatMessageID, arg.WorkspaceID, arg.TaskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const countUnboundChatAttachmentsForTask = `-- name: CountUnboundChatAttachmentsForTask :one
|
|
SELECT COUNT(*) FROM attachment
|
|
WHERE workspace_id = $1
|
|
AND task_id = $2
|
|
AND issue_id IS NULL
|
|
AND comment_id IS NULL
|
|
AND chat_message_id IS NULL
|
|
`
|
|
|
|
type CountUnboundChatAttachmentsForTaskParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
TaskID pgtype.UUID `json:"task_id"`
|
|
}
|
|
|
|
// How many attachments the agent produced for this chat task that are still
|
|
// unbound to any owner. Lets CompleteTask create an assistant message (and
|
|
// bind them) even when the agent's text output was empty but it uploaded files.
|
|
func (q *Queries) CountUnboundChatAttachmentsForTask(ctx context.Context, arg CountUnboundChatAttachmentsForTaskParams) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countUnboundChatAttachmentsForTask, arg.WorkspaceID, arg.TaskID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const createAttachment = `-- name: CreateAttachment :one
|
|
INSERT INTO attachment (
|
|
id, workspace_id, issue_id, comment_id, chat_session_id, task_id,
|
|
uploader_type, uploader_id, filename, url, content_type, size_bytes
|
|
)
|
|
VALUES (
|
|
$1, $2, $9, $10, $11, $12,
|
|
$3, $4, $5, $6, $7, $8
|
|
)
|
|
RETURNING id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id, task_id
|
|
`
|
|
|
|
type CreateAttachmentParams struct {
|
|
ID pgtype.UUID `json:"id"`
|
|
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"`
|
|
ChatSessionID pgtype.UUID `json:"chat_session_id"`
|
|
TaskID pgtype.UUID `json:"task_id"`
|
|
}
|
|
|
|
func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) (Attachment, error) {
|
|
row := q.db.QueryRow(ctx, createAttachment,
|
|
arg.ID,
|
|
arg.WorkspaceID,
|
|
arg.UploaderType,
|
|
arg.UploaderID,
|
|
arg.Filename,
|
|
arg.Url,
|
|
arg.ContentType,
|
|
arg.SizeBytes,
|
|
arg.IssueID,
|
|
arg.CommentID,
|
|
arg.ChatSessionID,
|
|
arg.TaskID,
|
|
)
|
|
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,
|
|
&i.ChatSessionID,
|
|
&i.ChatMessageID,
|
|
&i.TaskID,
|
|
)
|
|
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 detachAttachmentsFromUserChatMessageByTask = `-- name: DetachAttachmentsFromUserChatMessageByTask :many
|
|
UPDATE attachment
|
|
SET chat_message_id = NULL
|
|
WHERE chat_message_id IN (
|
|
SELECT id FROM chat_message WHERE chat_message.task_id = $1 AND role = 'user'
|
|
)
|
|
RETURNING id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id, task_id
|
|
`
|
|
|
|
// When an empty chat task is cancelled, its user message is deleted. The
|
|
// attachment FK is ON DELETE CASCADE, so without this the bound rows would be
|
|
// destroyed and a restored draft could never re-bind them. Detach first
|
|
// (chat_message_id -> NULL, keep chat_session_id) so the rows survive as
|
|
// workspace/session-scoped unattached attachments and re-send can re-link them.
|
|
func (q *Queries) DetachAttachmentsFromUserChatMessageByTask(ctx context.Context, taskID pgtype.UUID) ([]Attachment, error) {
|
|
rows, err := q.db.Query(ctx, detachAttachmentsFromUserChatMessageByTask, taskID)
|
|
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,
|
|
&i.ChatSessionID,
|
|
&i.ChatMessageID,
|
|
&i.TaskID,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
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, chat_session_id, chat_message_id, task_id 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,
|
|
&i.ChatSessionID,
|
|
&i.ChatMessageID,
|
|
&i.TaskID,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getAttachmentByIDOnly = `-- name: GetAttachmentByIDOnly :one
|
|
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id, task_id FROM attachment
|
|
WHERE id = $1
|
|
`
|
|
|
|
// Used by the download endpoint, which derives workspace context from the
|
|
// attachment row itself rather than from request headers/query params. The
|
|
// caller still has to verify the requester is a member of the returned
|
|
// workspace_id before serving the bytes — this query is access-neutral on
|
|
// purpose so a self-contained URL like /api/attachments/{id}/download can
|
|
// work as a native <img>/<video> resource load (no header attachment).
|
|
func (q *Queries) GetAttachmentByIDOnly(ctx context.Context, id pgtype.UUID) (Attachment, error) {
|
|
row := q.db.QueryRow(ctx, getAttachmentByIDOnly, id)
|
|
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,
|
|
&i.ChatSessionID,
|
|
&i.ChatMessageID,
|
|
&i.TaskID,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const linkAttachmentsToChatMessage = `-- name: LinkAttachmentsToChatMessage :many
|
|
UPDATE attachment
|
|
SET chat_message_id = $1,
|
|
chat_session_id = $2
|
|
WHERE workspace_id = $3
|
|
AND issue_id IS NULL
|
|
AND comment_id IS NULL
|
|
AND chat_message_id IS NULL
|
|
AND (
|
|
chat_session_id IS NULL
|
|
OR chat_session_id = $2
|
|
)
|
|
AND uploader_type = $4
|
|
AND uploader_id = $5
|
|
AND id = ANY($6::uuid[])
|
|
RETURNING id
|
|
`
|
|
|
|
type LinkAttachmentsToChatMessageParams struct {
|
|
ChatMessageID pgtype.UUID `json:"chat_message_id"`
|
|
ChatSessionID pgtype.UUID `json:"chat_session_id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
UploaderType string `json:"uploader_type"`
|
|
UploaderID pgtype.UUID `json:"uploader_id"`
|
|
AttachmentIds []pgtype.UUID `json:"attachment_ids"`
|
|
}
|
|
|
|
func (q *Queries) LinkAttachmentsToChatMessage(ctx context.Context, arg LinkAttachmentsToChatMessageParams) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, linkAttachmentsToChatMessage,
|
|
arg.ChatMessageID,
|
|
arg.ChatSessionID,
|
|
arg.WorkspaceID,
|
|
arg.UploaderType,
|
|
arg.UploaderID,
|
|
arg.AttachmentIds,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []pgtype.UUID{}
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const linkAttachmentsToComment = `-- name: LinkAttachmentsToComment :exec
|
|
UPDATE attachment
|
|
SET comment_id = $1
|
|
WHERE issue_id = $2
|
|
AND comment_id IS NULL
|
|
AND id = ANY($3::uuid[])
|
|
`
|
|
|
|
type LinkAttachmentsToCommentParams struct {
|
|
CommentID pgtype.UUID `json:"comment_id"`
|
|
IssueID pgtype.UUID `json:"issue_id"`
|
|
Column3 []pgtype.UUID `json:"column_3"`
|
|
}
|
|
|
|
func (q *Queries) LinkAttachmentsToComment(ctx context.Context, arg LinkAttachmentsToCommentParams) error {
|
|
_, err := q.db.Exec(ctx, linkAttachmentsToComment, arg.CommentID, arg.IssueID, arg.Column3)
|
|
return err
|
|
}
|
|
|
|
const linkAttachmentsToIssue = `-- name: LinkAttachmentsToIssue :exec
|
|
UPDATE attachment
|
|
SET issue_id = $1
|
|
WHERE workspace_id = $2
|
|
AND issue_id IS NULL
|
|
AND id = ANY($3::uuid[])
|
|
`
|
|
|
|
type LinkAttachmentsToIssueParams struct {
|
|
IssueID pgtype.UUID `json:"issue_id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Column3 []pgtype.UUID `json:"column_3"`
|
|
}
|
|
|
|
func (q *Queries) LinkAttachmentsToIssue(ctx context.Context, arg LinkAttachmentsToIssueParams) error {
|
|
_, err := q.db.Exec(ctx, linkAttachmentsToIssue, arg.IssueID, arg.WorkspaceID, arg.Column3)
|
|
return err
|
|
}
|
|
|
|
const listAttachmentURLsByCommentID = `-- name: ListAttachmentURLsByCommentID :many
|
|
SELECT url FROM attachment
|
|
WHERE comment_id = $1
|
|
`
|
|
|
|
func (q *Queries) ListAttachmentURLsByCommentID(ctx context.Context, commentID pgtype.UUID) ([]string, error) {
|
|
rows, err := q.db.Query(ctx, listAttachmentURLsByCommentID, commentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []string{}
|
|
for rows.Next() {
|
|
var url string
|
|
if err := rows.Scan(&url); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, url)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAttachmentURLsByIssueOrComments = `-- name: ListAttachmentURLsByIssueOrComments :many
|
|
SELECT a.url FROM attachment a
|
|
WHERE a.issue_id = $1
|
|
OR a.comment_id IN (SELECT c.id FROM comment c WHERE c.issue_id = $1)
|
|
`
|
|
|
|
func (q *Queries) ListAttachmentURLsByIssueOrComments(ctx context.Context, issueID pgtype.UUID) ([]string, error) {
|
|
rows, err := q.db.Query(ctx, listAttachmentURLsByIssueOrComments, issueID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []string{}
|
|
for rows.Next() {
|
|
var url string
|
|
if err := rows.Scan(&url); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, url)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAttachmentsByChatMessage = `-- name: ListAttachmentsByChatMessage :many
|
|
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id, task_id FROM attachment
|
|
WHERE chat_message_id = $1 AND workspace_id = $2
|
|
ORDER BY created_at ASC
|
|
`
|
|
|
|
type ListAttachmentsByChatMessageParams struct {
|
|
ChatMessageID pgtype.UUID `json:"chat_message_id"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
func (q *Queries) ListAttachmentsByChatMessage(ctx context.Context, arg ListAttachmentsByChatMessageParams) ([]Attachment, error) {
|
|
rows, err := q.db.Query(ctx, listAttachmentsByChatMessage, arg.ChatMessageID, 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,
|
|
&i.ChatSessionID,
|
|
&i.ChatMessageID,
|
|
&i.TaskID,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAttachmentsByChatMessageIDs = `-- name: ListAttachmentsByChatMessageIDs :many
|
|
SELECT id, workspace_id, issue_id, comment_id, uploader_type, uploader_id, filename, url, content_type, size_bytes, created_at, chat_session_id, chat_message_id, task_id FROM attachment
|
|
WHERE chat_message_id = ANY($1::uuid[]) AND workspace_id = $2
|
|
ORDER BY created_at ASC
|
|
`
|
|
|
|
type ListAttachmentsByChatMessageIDsParams struct {
|
|
Column1 []pgtype.UUID `json:"column_1"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
func (q *Queries) ListAttachmentsByChatMessageIDs(ctx context.Context, arg ListAttachmentsByChatMessageIDsParams) ([]Attachment, error) {
|
|
rows, err := q.db.Query(ctx, listAttachmentsByChatMessageIDs, arg.Column1, 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,
|
|
&i.ChatSessionID,
|
|
&i.ChatMessageID,
|
|
&i.TaskID,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
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, chat_session_id, chat_message_id, task_id 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,
|
|
&i.ChatSessionID,
|
|
&i.ChatMessageID,
|
|
&i.TaskID,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
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, chat_session_id, chat_message_id, task_id FROM attachment
|
|
WHERE comment_id = ANY($1::uuid[]) AND workspace_id = $2
|
|
ORDER BY created_at ASC
|
|
`
|
|
|
|
type ListAttachmentsByCommentIDsParams struct {
|
|
Column1 []pgtype.UUID `json:"column_1"`
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
}
|
|
|
|
func (q *Queries) ListAttachmentsByCommentIDs(ctx context.Context, arg ListAttachmentsByCommentIDsParams) ([]Attachment, error) {
|
|
rows, err := q.db.Query(ctx, listAttachmentsByCommentIDs, arg.Column1, 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,
|
|
&i.ChatSessionID,
|
|
&i.ChatMessageID,
|
|
&i.TaskID,
|
|
); 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, chat_session_id, chat_message_id, task_id 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,
|
|
&i.ChatSessionID,
|
|
&i.ChatMessageID,
|
|
&i.TaskID,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const replaceCommentAttachments = `-- name: ReplaceCommentAttachments :exec
|
|
UPDATE attachment
|
|
SET comment_id = CASE
|
|
WHEN id = ANY($3::uuid[]) THEN $1
|
|
ELSE NULL
|
|
END
|
|
WHERE issue_id = $2
|
|
AND (
|
|
comment_id = $1
|
|
OR (comment_id IS NULL AND id = ANY($3::uuid[]))
|
|
)
|
|
`
|
|
|
|
type ReplaceCommentAttachmentsParams struct {
|
|
CommentID pgtype.UUID `json:"comment_id"`
|
|
IssueID pgtype.UUID `json:"issue_id"`
|
|
AttachmentIds []pgtype.UUID `json:"attachment_ids"`
|
|
}
|
|
|
|
func (q *Queries) ReplaceCommentAttachments(ctx context.Context, arg ReplaceCommentAttachmentsParams) error {
|
|
_, err := q.db.Exec(ctx, replaceCommentAttachments, arg.CommentID, arg.IssueID, arg.AttachmentIds)
|
|
return err
|
|
}
|