diff --git a/ROADMAP.md b/ROADMAP.md index 7467db3..7ba8148 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,6 +48,8 @@ Goal: ship a professional open-source customer messaging platform with strong de - [ ] pull available models from provider API and display them in the settings UI to control which model is used - [ ] allow customising the agent's system prompt? - [p] a CI AI agent to check for any doc drift and update docs based on the latest code +- [ ] convert supportAttachments.finalizeUpload into an action + internal mutation pipeline so we can add real signature checks too. The current finalizeUpload boundary is a Convex mutation and ctx.storage.get() is only available in actions. Doing true magic-byte validation would need a larger refactor of that finalize flow. + apps/web/src/app/outbound/[id]/OutboundTriggerPanel.tsx Comment on lines +67 to 71 diff --git a/apps/web/e2e/widget.spec.ts b/apps/web/e2e/widget.spec.ts index e72cf7c..a5774ff 100644 --- a/apps/web/e2e/widget.spec.ts +++ b/apps/web/e2e/widget.spec.ts @@ -361,7 +361,8 @@ test.describe("Widget Email Capture", () => { await expect(capturePrompt).not.toBeVisible({ timeout: 20000 }); }); - test("should dismiss email capture prompt", async ({ page }) => { +// Skipped since we have hidden the dismiss button for now + test.skip("should dismiss email capture prompt", async ({ page }) => { const widget = await openWidgetChatOrSkip(page); await startConversationIfNeeded(widget); diff --git a/apps/web/src/app/inbox/InboxThreadPane.test.tsx b/apps/web/src/app/inbox/InboxThreadPane.test.tsx index e1bdd9e..964f773 100644 --- a/apps/web/src/app/inbox/InboxThreadPane.test.tsx +++ b/apps/web/src/app/inbox/InboxThreadPane.test.tsx @@ -86,7 +86,9 @@ function buildProps(overrides?: Partial { - it("shows recent content, snippet actions, and snippet workflow controls in the consolidated picker", () => { + it("shows recent content and snippet actions in the consolidated picker", () => { const props = buildProps(); render(); expect(screen.getByText("Recently Used")).toBeInTheDocument(); expect(screen.getByText("Snippets")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Save snippet" })).toBeEnabled(); - expect(screen.getByRole("button", { name: /Update "Billing follow-up"/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save snippet" })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Update "Billing follow-up"/ }) + ).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Insert Link" })); expect(props.onInsertKnowledgeContent).toHaveBeenCalledWith( @@ -142,15 +148,17 @@ describe("InboxThreadPane", () => { ); }); - it("invokes the inline snippet workflow controls from the composer", () => { + it("does not render inline snippet workflow controls in the composer", () => { const props = buildProps(); render(); - fireEvent.click(screen.getByRole("button", { name: "Save snippet" })); - fireEvent.click(screen.getByRole("button", { name: /Update "Billing follow-up"/ })); + expect(screen.queryByRole("button", { name: "Save snippet" })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Update "Billing follow-up"/ }) + ).not.toBeInTheDocument(); - expect(props.onSaveDraftAsSnippet).toHaveBeenCalledTimes(1); - expect(props.onUpdateSnippetFromDraft).toHaveBeenCalledTimes(1); + expect(props.onSaveDraftAsSnippet).not.toHaveBeenCalled(); + expect(props.onUpdateSnippetFromDraft).not.toHaveBeenCalled(); }); it("inserts snippet content directly from the consolidated picker", () => { @@ -189,4 +197,48 @@ describe("InboxThreadPane", () => { expect.objectContaining({ id: "article-3", type: "internalArticle" }) ); }); + + it("renders message attachments and queued reply attachments", () => { + const props = buildProps({ + inputValue: "", + pendingAttachments: [ + { + attachmentId: "support-attachment-1" as Id<"supportAttachments">, + fileName: "error-screenshot.png", + mimeType: "image/png", + size: 2048, + status: "staged", + }, + ], + messages: [ + { + _id: messageId("message-attachment"), + senderType: "visitor" as const, + content: "", + createdAt: Date.now(), + attachments: [ + { + _id: "attached-file-1", + fileName: "browser-log.txt", + mimeType: "text/plain", + size: 1536, + url: "https://example.com/browser-log.txt", + }, + ], + }, + ], + }); + + render(); + + expect(screen.getByText("browser-log.txt")).toBeInTheDocument(); + expect(screen.getByTestId("inbox-pending-attachments")).toBeInTheDocument(); + expect(screen.getByText("error-screenshot.png")).toBeInTheDocument(); + expect(screen.getByTestId("inbox-send-button")).toBeEnabled(); + + fireEvent.click(screen.getByLabelText("Remove error-screenshot.png")); + expect(props.onRemovePendingAttachment).toHaveBeenCalledWith( + "support-attachment-1" as Id<"supportAttachments"> + ); + }); }); diff --git a/apps/web/src/app/inbox/InboxThreadPane.tsx b/apps/web/src/app/inbox/InboxThreadPane.tsx index 7912ef9..caa464b 100644 --- a/apps/web/src/app/inbox/InboxThreadPane.tsx +++ b/apps/web/src/app/inbox/InboxThreadPane.tsx @@ -1,6 +1,12 @@ "use client"; +import { useRef } from "react"; import { Button, Card, Input } from "@opencom/ui"; +import { + SUPPORT_ATTACHMENT_ACCEPT, + formatSupportAttachmentSize, + type StagedSupportAttachment, +} from "@opencom/web-shared"; import { ArrowLeft, ArrowUpRight, @@ -16,7 +22,7 @@ import { ShieldAlert, Ticket, X, - Zap, + // Zap, } from "lucide-react"; import type { Id } from "@opencom/convex/dataModel"; import { @@ -35,7 +41,9 @@ interface InboxThreadPaneProps { workflowError: string | null; highlightedMessageId: Id<"messages"> | null; inputValue: string; + pendingAttachments?: StagedSupportAttachment>[]; isSending: boolean; + isUploadingAttachments?: boolean; isResolving: boolean; isConvertingTicket: boolean; showKnowledgePicker: boolean; @@ -62,6 +70,8 @@ interface InboxThreadPaneProps { onInputChange: (value: string) => void; onInputKeyDown: (event: React.KeyboardEvent) => void; onSendMessage: () => void; + onUploadAttachments?: (files: File[]) => void; + onRemovePendingAttachment?: (attachmentId: Id<"supportAttachments">) => void; onToggleKnowledgePicker: () => void; onKnowledgeSearchChange: (value: string) => void; onCloseKnowledgePicker: () => void; @@ -124,7 +134,9 @@ export function InboxThreadPane({ workflowError, highlightedMessageId, inputValue, + pendingAttachments = [], isSending, + isUploadingAttachments = false, isResolving, isConvertingTicket, showKnowledgePicker, @@ -138,9 +150,9 @@ export function InboxThreadPane({ isSidecarEnabled, suggestionsCount, isSuggestionsCountLoading, - canSaveDraftAsSnippet, - canUpdateSnippetFromDraft, - lastInsertedSnippetName, + // canSaveDraftAsSnippet, + // canUpdateSnippetFromDraft, + // lastInsertedSnippetName, replyInputRef, onBackToList, onResolveConversation, @@ -151,20 +163,32 @@ export function InboxThreadPane({ onInputChange, onInputKeyDown, onSendMessage, + onUploadAttachments = () => {}, + onRemovePendingAttachment = () => {}, onToggleKnowledgePicker, onKnowledgeSearchChange, onCloseKnowledgePicker, onInsertKnowledgeContent, - onSaveDraftAsSnippet, - onUpdateSnippetFromDraft, + // onSaveDraftAsSnippet, + // onUpdateSnippetFromDraft, getConversationIdentityLabel, getHandoffReasonLabel, }: InboxThreadPaneProps): React.JSX.Element { + const attachmentInputRef = useRef(null); const normalizedKnowledgeSearch = knowledgeSearch.trim(); const isSearchingKnowledge = normalizedKnowledgeSearch.length > 0; const hasRecentContent = Boolean(recentContent && recentContent.length > 0); const hasSnippetLibrary = Boolean(allSnippets && allSnippets.length > 0); const hasKnowledgeResults = Boolean(knowledgeResults && knowledgeResults.length > 0); + const canSendReply = inputValue.trim().length > 0 || pendingAttachments.length > 0; + + const handleAttachmentInputChange = (event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + if (files.length > 0) { + onUploadAttachments(files); + } + event.target.value = ""; + }; const renderKnowledgeActions = (item: InboxKnowledgeItem) => { if (item.type === "snippet") { @@ -440,7 +464,11 @@ export function InboxThreadPane({ From: {message.emailMetadata.from} )} -

{message.content.replace(/<[^>]*>/g, "")}

+ {message.content.trim().length > 0 && ( +

+ {message.content.replace(/<[^>]*>/g, "")} +

+ )} {message.emailMetadata?.attachments && message.emailMetadata.attachments.length > 0 && (
@@ -448,6 +476,47 @@ export function InboxThreadPane({ {message.emailMetadata.attachments.length} attachment(s)
)} + {message.attachments && message.attachments.length > 0 && ( +
+ {message.attachments.map((attachment) => ( +
+ {attachment.url ? ( + + + + {attachment.fileName} + + + {formatSupportAttachmentSize(attachment.size)} + + + ) : ( + <> + + + {attachment.fileName} + + + {formatSupportAttachmentSize(attachment.size)} + + + )} +
+ ))} +
+ )}
{message.channel === "email" && } @@ -563,7 +632,25 @@ export function InboxThreadPane({ )}
+
+ - - {canUpdateSnippetFromDraft ? ( + */} + {/* {canUpdateSnippetFromDraft ? ( - ) : null} + ) : null} */} +
+
+ {pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map((attachment) => ( +
+ + {attachment.fileName} + + {formatSupportAttachmentSize(attachment.size)} + + +
+ ))} +
+ )} + onInputChange(event.target.value)} + onKeyDown={onInputKeyDown} + placeholder="Type a message... (/ or Ctrl+K for knowledge)" + data-testid="inbox-reply-input" + disabled={isSending || isUploadingAttachments} + className="flex-1" + />
- onInputChange(event.target.value)} - onKeyDown={onInputKeyDown} - placeholder="Type a message... (/ or Ctrl+K for knowledge)" - data-testid="inbox-reply-input" - disabled={isSending} - className="flex-1" - /> diff --git a/apps/web/src/app/inbox/hooks/useInboxConvex.ts b/apps/web/src/app/inbox/hooks/useInboxConvex.ts index e81655c..2e55fc2 100644 --- a/apps/web/src/app/inbox/hooks/useInboxConvex.ts +++ b/apps/web/src/app/inbox/hooks/useInboxConvex.ts @@ -1,6 +1,7 @@ "use client"; import type { Id } from "@opencom/convex/dataModel"; +import type { SupportAttachmentFinalizeResult } from "@opencom/web-shared"; import { useWebAction, useWebMutation, @@ -44,6 +45,16 @@ type SendMessageArgs = { senderId: Id<"users">; senderType: "agent"; content: string; + attachmentIds?: Id<"supportAttachments">[]; +}; + +type SupportAttachmentUploadArgs = { + workspaceId: Id<"workspaces">; +}; + +type FinalizeSupportAttachmentUploadArgs = SupportAttachmentUploadArgs & { + storageId: Id<"_storage">; + fileName?: string; }; type MarkConversationReadArgs = { @@ -108,6 +119,14 @@ const AI_CONVERSATION_RESPONSES_QUERY_REF = webQueryRef("messages:send"); +const GENERATE_SUPPORT_ATTACHMENT_UPLOAD_URL_REF = webMutationRef< + SupportAttachmentUploadArgs, + string +>("supportAttachments:generateUploadUrl"); +const FINALIZE_SUPPORT_ATTACHMENT_UPLOAD_REF = webMutationRef< + FinalizeSupportAttachmentUploadArgs, + SupportAttachmentFinalizeResult> +>("supportAttachments:finalizeUpload"); const MARK_CONVERSATION_READ_REF = webMutationRef( "conversations:markAsRead" ); @@ -168,6 +187,10 @@ export function useInboxConvex({ conversationsData: useWebQuery(INBOX_CONVERSATIONS_QUERY_REF, inboxQueryArgs), createSnippet: useWebMutation(CREATE_SNIPPET_REF), convertToTicket: useWebMutation(CONVERT_CONVERSATION_TO_TICKET_REF), + finalizeSupportAttachmentUpload: useWebMutation(FINALIZE_SUPPORT_ATTACHMENT_UPLOAD_REF), + generateSupportAttachmentUploadUrl: useWebMutation( + GENERATE_SUPPORT_ATTACHMENT_UPLOAD_URL_REF + ), getSuggestionsForConversation: useWebAction(GET_SUGGESTIONS_FOR_CONVERSATION_REF), knowledgeResults: useWebQuery( KNOWLEDGE_SEARCH_QUERY_REF, diff --git a/apps/web/src/app/inbox/hooks/useInboxMessageActions.test.ts b/apps/web/src/app/inbox/hooks/useInboxMessageActions.test.ts new file mode 100644 index 0000000..4f41782 --- /dev/null +++ b/apps/web/src/app/inbox/hooks/useInboxMessageActions.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import type { Id } from "@opencom/convex/dataModel"; +import { shouldClearOptimisticLastMessage, type ConversationUiPatch } from "./useInboxMessageActions"; + +function messageId(value: string): Id<"messages"> { + return value as Id<"messages">; +} + +describe("shouldClearOptimisticLastMessage", () => { + it("waits for an agent message after the pre-send latest message", () => { + const patch: ConversationUiPatch = { + optimisticLastMessage: "1 attachment", + optimisticBaseMessageId: messageId("message-1"), + }; + + expect( + shouldClearOptimisticLastMessage(patch, [ + { _id: messageId("message-1"), senderType: "visitor" }, + ]) + ).toBe(false); + + expect( + shouldClearOptimisticLastMessage(patch, [ + { _id: messageId("message-1"), senderType: "visitor" }, + { _id: messageId("message-2"), senderType: "visitor" }, + ]) + ).toBe(false); + + expect( + shouldClearOptimisticLastMessage(patch, [ + { _id: messageId("message-1"), senderType: "visitor" }, + { _id: messageId("message-2"), senderType: "agent" }, + ]) + ).toBe(true); + }); + + it("clears when the first agent message appears in an empty thread", () => { + const patch: ConversationUiPatch = { + optimisticLastMessage: "Hello", + optimisticBaseMessageId: null, + }; + + expect( + shouldClearOptimisticLastMessage(patch, [ + { _id: messageId("message-1"), senderType: "agent" }, + ]) + ).toBe(true); + }); +}); diff --git a/apps/web/src/app/inbox/hooks/useInboxMessageActions.ts b/apps/web/src/app/inbox/hooks/useInboxMessageActions.ts index e36ce9a..9e624fc 100644 --- a/apps/web/src/app/inbox/hooks/useInboxMessageActions.ts +++ b/apps/web/src/app/inbox/hooks/useInboxMessageActions.ts @@ -1,6 +1,8 @@ import { useCallback } from "react"; import type { Dispatch, SetStateAction } from "react"; import type { Id } from "@opencom/convex/dataModel"; +import type { StagedSupportAttachment } from "@opencom/web-shared"; +import type { InboxMessage } from "../inboxRenderTypes"; type ConversationStatus = "open" | "closed" | "snoozed"; @@ -9,6 +11,7 @@ export type ConversationUiPatch = { status?: ConversationStatus; lastMessageAt?: number; optimisticLastMessage?: string; + optimisticBaseMessageId?: Id<"messages"> | null; }; interface ConversationSummaryForActions { @@ -24,6 +27,7 @@ interface MutationApi { senderId: Id<"users">; senderType: "agent"; content: string; + attachmentIds?: Id<"supportAttachments">[]; }) => Promise; updateStatus: (args: { id: Id<"conversations">; status: "closed" }) => Promise; convertToTicket: (args: { conversationId: Id<"conversations"> }) => Promise>; @@ -31,7 +35,11 @@ interface MutationApi { interface MutationState { inputValue: string; + pendingAttachments: StagedSupportAttachment>[]; setInputValue: Dispatch>; + setPendingAttachments: Dispatch< + SetStateAction>[]> + >; setIsSending: Dispatch>; setIsResolving: Dispatch>; setIsConvertingTicket: Dispatch>; @@ -45,6 +53,7 @@ interface MutationState { interface MutationContext { userId: Id<"users"> | null; selectedConversationId: Id<"conversations"> | null; + latestMessageId: Id<"messages"> | null; conversations: ConversationSummaryForActions[] | undefined; onTicketCreated: (ticketId: Id<"tickets">) => void; } @@ -63,11 +72,42 @@ export interface UseInboxMessageActionsResult { handleConvertToTicket: () => Promise; } +export function shouldClearOptimisticLastMessage( + patch: ConversationUiPatch | undefined, + messages: readonly Pick[] | undefined +): boolean { + if (!patch?.optimisticLastMessage || !messages || messages.length === 0) { + return false; + } + + const baseMessageIndex = patch.optimisticBaseMessageId + ? messages.findIndex((message) => message._id === patch.optimisticBaseMessageId) + : -1; + const messagesAfterOptimisticSend = + baseMessageIndex >= 0 ? messages.slice(baseMessageIndex + 1) : messages; + + return messagesAfterOptimisticSend.some((message) => message.senderType === "agent"); +} + export function useInboxMessageActions({ api, state, context, }: UseInboxMessageActionsArgs): UseInboxMessageActionsResult { + const getOptimisticLastMessage = useCallback( + (content: string) => { + if (content.trim()) { + return content; + } + const attachmentCount = state.pendingAttachments.length; + if (attachmentCount === 0) { + return ""; + } + return attachmentCount === 1 ? "1 attachment" : `${attachmentCount} attachments`; + }, + [state.pendingAttachments] + ); + const patchConversationState = useCallback( (conversationId: Id<"conversations">, patch: ConversationUiPatch) => { state.setConversationPatches((previousState) => ({ @@ -105,12 +145,17 @@ export function useInboxMessageActions({ ); const handleSendMessage = useCallback(async () => { - if (!state.inputValue.trim() || !context.selectedConversationId || !context.userId) { + if ( + (!state.inputValue.trim() && state.pendingAttachments.length === 0) || + !context.selectedConversationId || + !context.userId + ) { return; } const content = state.inputValue.trim(); const conversationId = context.selectedConversationId; + const attachmentIds = state.pendingAttachments.map((attachment) => attachment.attachmentId); const previousPatch = state.conversationPatches[conversationId]; const now = Date.now(); @@ -120,7 +165,8 @@ export function useInboxMessageActions({ patchConversationState(conversationId, { unreadByAgent: 0, lastMessageAt: now, - optimisticLastMessage: content, + optimisticLastMessage: getOptimisticLastMessage(content), + optimisticBaseMessageId: context.latestMessageId ?? null, }); try { @@ -129,7 +175,9 @@ export function useInboxMessageActions({ senderId: context.userId, senderType: "agent", content, + attachmentIds, }); + state.setPendingAttachments([]); } catch (error) { console.error("Failed to send message:", error); state.setInputValue(content); @@ -146,7 +194,15 @@ export function useInboxMessageActions({ } finally { state.setIsSending(false); } - }, [api, context.selectedConversationId, context.userId, patchConversationState, state]); + }, [ + api, + context.latestMessageId, + context.selectedConversationId, + context.userId, + getOptimisticLastMessage, + patchConversationState, + state, + ]); const handleResolveConversation = useCallback(async () => { if (!context.selectedConversationId) { diff --git a/apps/web/src/app/inbox/inboxRenderTypes.ts b/apps/web/src/app/inbox/inboxRenderTypes.ts index f229a8d..f3809de 100644 --- a/apps/web/src/app/inbox/inboxRenderTypes.ts +++ b/apps/web/src/app/inbox/inboxRenderTypes.ts @@ -1,6 +1,7 @@ "use client"; import type { Id } from "@opencom/convex/dataModel"; +import type { SupportAttachmentDescriptor } from "@opencom/web-shared"; export type InboxAiWorkflowFilter = "all" | "ai_handled" | "handoff"; export type InboxCompactPanel = "ai-review" | "suggestions" | null; @@ -44,6 +45,7 @@ export interface InboxMessage { from?: string; attachments?: Array<{ filename?: string; contentType?: string }>; } | null; + attachments?: SupportAttachmentDescriptor[]; } export interface InboxSnippet { diff --git a/apps/web/src/app/inbox/page.tsx b/apps/web/src/app/inbox/page.tsx index 700d4dd..b7ed144 100644 --- a/apps/web/src/app/inbox/page.tsx +++ b/apps/web/src/app/inbox/page.tsx @@ -2,6 +2,11 @@ import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import type { Id } from "@opencom/convex/dataModel"; +import { + normalizeUnknownError, + uploadSupportAttachments, + type StagedSupportAttachment, +} from "@opencom/web-shared"; import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/contexts/AuthContext"; import { AppLayout, AppPageShell } from "@/components/AppLayout"; @@ -19,6 +24,7 @@ import { useInboxSuggestionsCount } from "./hooks/useInboxSuggestionsCount"; import { useInboxAttentionCues } from "./hooks/useInboxAttentionCues"; import { useInboxConvex } from "./hooks/useInboxConvex"; import { + shouldClearOptimisticLastMessage, useInboxMessageActions, type ConversationUiPatch, } from "./hooks/useInboxMessageActions"; @@ -75,6 +81,10 @@ function InboxContent(): React.JSX.Element | null { null ); const [inputValue, setInputValue] = useState(""); + const [pendingAttachments, setPendingAttachments] = useState< + StagedSupportAttachment>[] + >([]); + const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); const [showKnowledgePicker, setShowKnowledgePicker] = useState(false); const [knowledgeSearch, setKnowledgeSearch] = useState(""); const [snippetDialogMode, setSnippetDialogMode] = useState<"create" | "update" | null>(null); @@ -98,6 +108,10 @@ function InboxContent(): React.JSX.Element | null { const [highlightedMessageId, setHighlightedMessageId] = useState | null>(null); const messageHighlightTimerRef = useRef | null>(null); const replyInputRef = useRef(null); + const selectedConversationIdRef = useRef | null>(selectedConversationId); + const attachmentUploadContextVersionRef = useRef(0); + const attachmentUploadRequestIdRef = useRef(0); + selectedConversationIdRef.current = selectedConversationId; const { aiResponses, aiSettings, @@ -105,6 +119,8 @@ function InboxContent(): React.JSX.Element | null { conversationsData, createSnippet, convertToTicket, + finalizeSupportAttachmentUpload, + generateSupportAttachmentUploadUrl, getSuggestionsForConversation, knowledgeResults, markAsRead, @@ -220,26 +236,31 @@ function InboxContent(): React.JSX.Element | null { return () => document.removeEventListener("keydown", handleGlobalKeyDown); }, []); + useEffect(() => { + attachmentUploadContextVersionRef.current += 1; + attachmentUploadRequestIdRef.current += 1; + setPendingAttachments([]); + setIsUploadingAttachments(false); + }, [selectedConversationId]); + useEffect(() => { if (!selectedConversationId || !messages || messages.length === 0) { return; } - const latestMessage = messages[messages.length - 1]; setConversationPatches((previousState) => { const patch = previousState[selectedConversationId]; - if (!patch?.optimisticLastMessage) { - return previousState; - } - if (latestMessage.content !== patch.optimisticLastMessage) { + if (!shouldClearOptimisticLastMessage(patch, messages)) { return previousState; } + const latestMessage = messages[messages.length - 1]; const nextState = { ...previousState }; const nextPatch: ConversationUiPatch = { ...patch, lastMessageAt: latestMessage.createdAt, }; delete nextPatch.optimisticLastMessage; + delete nextPatch.optimisticBaseMessageId; if ( nextPatch.unreadByAgent === undefined && @@ -291,7 +312,9 @@ function InboxContent(): React.JSX.Element | null { }, state: { inputValue, + pendingAttachments, setInputValue, + setPendingAttachments, setIsSending, setIsResolving, setIsConvertingTicket, @@ -304,6 +327,7 @@ function InboxContent(): React.JSX.Element | null { context: { userId: user?._id ?? null, selectedConversationId, + latestMessageId: messages?.[messages.length - 1]?._id ?? null, conversations, onTicketCreated: (ticketId) => router.push(`/tickets/${ticketId}`), }, @@ -440,6 +464,69 @@ function InboxContent(): React.JSX.Element | null { const lastInsertedSnippet = allSnippets?.find((snippet) => snippet._id === lastInsertedSnippetId) ?? null; + const handleUploadAttachments = useCallback( + async (files: File[]) => { + if (!activeWorkspace?._id || !selectedConversationId || files.length === 0) { + return; + } + + const uploadConversationId = selectedConversationId; + const uploadContextVersion = attachmentUploadContextVersionRef.current; + const uploadRequestId = attachmentUploadRequestIdRef.current + 1; + attachmentUploadRequestIdRef.current = uploadRequestId; + setWorkflowError(null); + setIsUploadingAttachments(true); + try { + const uploadedAttachments = await uploadSupportAttachments({ + files, + currentCount: pendingAttachments.length, + workspaceId: activeWorkspace._id, + generateUploadUrl: generateSupportAttachmentUploadUrl, + finalizeUpload: finalizeSupportAttachmentUpload, + }); + if ( + selectedConversationIdRef.current !== uploadConversationId || + attachmentUploadContextVersionRef.current !== uploadContextVersion || + attachmentUploadRequestIdRef.current !== uploadRequestId + ) { + return; + } + setPendingAttachments((current) => [...current, ...uploadedAttachments]); + } catch (error) { + if ( + selectedConversationIdRef.current !== uploadConversationId || + attachmentUploadContextVersionRef.current !== uploadContextVersion || + attachmentUploadRequestIdRef.current !== uploadRequestId + ) { + return; + } + setWorkflowError( + normalizeUnknownError(error, { + fallbackMessage: "Failed to upload attachment.", + nextAction: "Try again with a supported file.", + }).message + ); + } finally { + if (attachmentUploadRequestIdRef.current === uploadRequestId) { + setIsUploadingAttachments(false); + } + } + }, + [ + activeWorkspace?._id, + finalizeSupportAttachmentUpload, + generateSupportAttachmentUploadUrl, + pendingAttachments.length, + selectedConversationId, + ] + ); + + const handleRemovePendingAttachment = useCallback((attachmentId: Id<"supportAttachments">) => { + setPendingAttachments((current) => + current.filter((attachment) => attachment.attachmentId !== attachmentId) + ); + }, []); + if (!user || !activeWorkspace) { return null; } @@ -476,7 +563,9 @@ function InboxContent(): React.JSX.Element | null { workflowError={workflowError} highlightedMessageId={highlightedMessageId} inputValue={inputValue} + pendingAttachments={pendingAttachments} isSending={isSending} + isUploadingAttachments={isUploadingAttachments} isResolving={isResolving} isConvertingTicket={isConvertingTicket} showKnowledgePicker={showKnowledgePicker} @@ -518,6 +607,10 @@ function InboxContent(): React.JSX.Element | null { onSendMessage={() => { void handleSendMessage(); }} + onUploadAttachments={(files) => { + void handleUploadAttachments(files); + }} + onRemovePendingAttachment={handleRemovePendingAttachment} onKnowledgeSearchChange={setKnowledgeSearch} onToggleKnowledgePicker={() => { setShowKnowledgePicker((current) => !current); diff --git a/apps/web/src/app/tickets/[id]/page.tsx b/apps/web/src/app/tickets/[id]/page.tsx index 498f08d..b14adb7 100644 --- a/apps/web/src/app/tickets/[id]/page.tsx +++ b/apps/web/src/app/tickets/[id]/page.tsx @@ -1,6 +1,14 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; +import { + SUPPORT_ATTACHMENT_ACCEPT, + formatSupportAttachmentSize, + normalizeUnknownError, + uploadSupportAttachments, + type StagedSupportAttachment, + type SupportAttachmentDescriptor, +} from "@opencom/web-shared"; import { Button, Card, Input } from "@opencom/ui"; import { ArrowLeft, @@ -12,6 +20,8 @@ import { Lock, Unlock, MessageSquare, + Paperclip, + X, } from "lucide-react"; import type { Id } from "@opencom/convex/dataModel"; import { useAuth } from "@/contexts/AuthContext"; @@ -45,6 +55,44 @@ const priorityConfig: Record = urgent: { label: "Urgent", color: "bg-red-100 text-red-600" }, }; +function renderAttachmentRow(attachment: SupportAttachmentDescriptor): React.JSX.Element { + const content = ( + <> + + + {attachment.fileName} + + + {formatSupportAttachmentSize(attachment.size)} + + + ); + + if (attachment.url) { + return ( + + {content} + + ); + } + + return ( +
+ {content} +
+ ); +} + function TicketDetailContent(): React.JSX.Element | null { const { user, activeWorkspace } = useAuth(); const params = useParams(); @@ -54,8 +102,21 @@ function TicketDetailContent(): React.JSX.Element | null { const [isInternal, setIsInternal] = useState(false); const [showResolveModal, setShowResolveModal] = useState(false); const [resolutionSummary, setResolutionSummary] = useState(""); - const { addComment, resolveTicket, ticketResult, updateTicket, workspaceUsers } = - useTicketDetailConvex(ticketId, activeWorkspace?._id); + const [pendingAttachments, setPendingAttachments] = useState< + StagedSupportAttachment>[] + >([]); + const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); + const [commentError, setCommentError] = useState(null); + const attachmentInputRef = useRef(null); + const { + addComment, + finalizeSupportAttachmentUpload, + generateSupportAttachmentUploadUrl, + resolveTicket, + ticketResult, + updateTicket, + workspaceUsers, + } = useTicketDetailConvex(ticketId, activeWorkspace?._id); const handleStatusChange = async (newStatus: TicketStatus) => { if (!ticketId) return; @@ -87,19 +148,56 @@ function TicketDetailContent(): React.JSX.Element | null { } }; + const handleUploadAttachments = async (files: File[]) => { + if (!activeWorkspace?._id || files.length === 0) { + return; + } + + setCommentError(null); + setIsUploadingAttachments(true); + try { + const uploadedAttachments = await uploadSupportAttachments({ + files, + currentCount: pendingAttachments.length, + workspaceId: activeWorkspace._id, + generateUploadUrl: generateSupportAttachmentUploadUrl, + finalizeUpload: finalizeSupportAttachmentUpload, + }); + setPendingAttachments((current) => [...current, ...uploadedAttachments]); + } catch (error) { + setCommentError( + normalizeUnknownError(error, { + fallbackMessage: "Failed to upload attachment.", + nextAction: "Try again with a supported file.", + }).message + ); + } finally { + setIsUploadingAttachments(false); + } + }; + const handleAddComment = async () => { - if (!ticketId || !user || !commentContent.trim()) return; + if (!ticketId || !user || (!commentContent.trim() && pendingAttachments.length === 0)) return; try { + setCommentError(null); await addComment({ ticketId, authorId: user._id, authorType: "agent", content: commentContent.trim(), + attachmentIds: pendingAttachments.map((attachment) => attachment.attachmentId), isInternal, }); setCommentContent(""); + setPendingAttachments([]); } catch (error) { console.error("Failed to add comment:", error); + setCommentError( + normalizeUnknownError(error, { + fallbackMessage: "Failed to add comment.", + nextAction: "Please try again.", + }).message + ); } }; @@ -215,6 +313,20 @@ function TicketDetailContent(): React.JSX.Element | null {

Timeline

+ { + const files = Array.from(event.target.files ?? []); + if (files.length > 0) { + void handleUploadAttachments(files); + } + event.target.value = ""; + }} + /> {/* Description */} {ticket.description && ( @@ -224,6 +336,15 @@ function TicketDetailContent(): React.JSX.Element | null {
)} + {ticket.attachments && ticket.attachments.length > 0 && ( +
+

Attachments

+
+ {ticket.attachments.map((attachment) => renderAttachmentRow(attachment))} +
+
+ )} + {/* Comments */}
{ticket.comments?.map((comment) => ( @@ -253,11 +374,18 @@ function TicketDetailContent(): React.JSX.Element | null { {new Date(comment.createdAt).toLocaleString()}
-

{comment.content}

+ {comment.content.trim().length > 0 &&

{comment.content}

} + {comment.attachments && comment.attachments.length > 0 && ( +
+ {comment.attachments.map((attachment) => renderAttachmentRow(attachment))} +
+ )}
))} - {(!ticket.comments || ticket.comments.length === 0) && !ticket.description && ( + {(!ticket.comments || ticket.comments.length === 0) && + !ticket.description && + (!ticket.attachments || ticket.attachments.length === 0) && (

No activity yet

)}
@@ -265,6 +393,11 @@ function TicketDetailContent(): React.JSX.Element | null { {/* Add Comment */} {ticket.status !== "resolved" && (
+ {commentError && ( +
+ {commentError} +
+ )}
+
+ {pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map((attachment) => ( +
+ + {attachment.fileName} + + {formatSupportAttachmentSize(attachment.size)} + + +
+ ))} +
+ )}
setCommentContent(e.target.value)} placeholder={isInternal ? "Add internal note..." : "Reply to customer..."} className="flex-1" + disabled={isUploadingAttachments} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -298,7 +473,10 @@ function TicketDetailContent(): React.JSX.Element | null { } }} /> -
diff --git a/apps/web/src/app/tickets/hooks/useTicketsConvex.ts b/apps/web/src/app/tickets/hooks/useTicketsConvex.ts index 231831e..f939ebf 100644 --- a/apps/web/src/app/tickets/hooks/useTicketsConvex.ts +++ b/apps/web/src/app/tickets/hooks/useTicketsConvex.ts @@ -1,6 +1,10 @@ "use client"; import type { Id } from "@opencom/convex/dataModel"; +import type { + SupportAttachmentDescriptor, + SupportAttachmentFinalizeResult, +} from "@opencom/web-shared"; import { useWebMutation, useWebQuery, @@ -70,10 +74,21 @@ const CREATE_TICKET_REF = webMutationRef< visitorId?: Id<"visitors">; subject: string; description?: string; + attachmentIds?: Id<"supportAttachments">[]; priority?: TicketPriority; }, Id<"tickets"> >("tickets:create"); +const GENERATE_SUPPORT_ATTACHMENT_UPLOAD_URL_REF = webMutationRef( + "supportAttachments:generateUploadUrl" +); +const FINALIZE_SUPPORT_ATTACHMENT_UPLOAD_REF = webMutationRef< + WorkspaceArgs & { + storageId: Id<"_storage">; + fileName?: string; + }, + SupportAttachmentFinalizeResult> +>("supportAttachments:finalizeUpload"); const TICKET_FORMS_LIST_QUERY_REF = webQueryRef< WorkspaceArgs, Array<{ @@ -159,6 +174,7 @@ const TICKET_DETAIL_QUERY_REF = webQueryRef< updatedAt: number; resolvedAt?: number; resolutionSummary?: string; + attachments?: SupportAttachmentDescriptor[]; visitor?: { _id: Id<"visitors">; readableId?: string; @@ -173,6 +189,7 @@ const TICKET_DETAIL_QUERY_REF = webQueryRef< content: string; isInternal: boolean; createdAt: number; + attachments?: SupportAttachmentDescriptor[]; }>; }; } @@ -202,6 +219,7 @@ const ADD_COMMENT_REF = webMutationRef< ticketId: Id<"tickets">; visitorId?: Id<"visitors">; content: string; + attachmentIds?: Id<"supportAttachments">[]; isInternal?: boolean; authorId?: string; authorType?: "agent" | "visitor" | "system"; @@ -221,6 +239,10 @@ export function useTicketsPageConvex( ) { return { createTicket: useWebMutation(CREATE_TICKET_REF), + finalizeSupportAttachmentUpload: useWebMutation(FINALIZE_SUPPORT_ATTACHMENT_UPLOAD_REF), + generateSupportAttachmentUploadUrl: useWebMutation( + GENERATE_SUPPORT_ATTACHMENT_UPLOAD_URL_REF + ), recentVisitors: useWebQuery( VISITORS_LIST_QUERY_REF, workspaceId && !visitorSearchQuery ? { workspaceId, limit: 10 } : "skip" @@ -259,6 +281,10 @@ export function useTicketDetailConvex( ) { return { addComment: useWebMutation(ADD_COMMENT_REF), + finalizeSupportAttachmentUpload: useWebMutation(FINALIZE_SUPPORT_ATTACHMENT_UPLOAD_REF), + generateSupportAttachmentUploadUrl: useWebMutation( + GENERATE_SUPPORT_ATTACHMENT_UPLOAD_URL_REF + ), resolveTicket: useWebMutation(RESOLVE_TICKET_REF), ticketResult: useWebQuery(TICKET_DETAIL_QUERY_REF, ticketId ? { id: ticketId } : "skip"), updateTicket: useWebMutation(UPDATE_TICKET_REF), diff --git a/apps/web/src/app/tickets/page.tsx b/apps/web/src/app/tickets/page.tsx index 660da42..ac64c6f 100644 --- a/apps/web/src/app/tickets/page.tsx +++ b/apps/web/src/app/tickets/page.tsx @@ -1,6 +1,13 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; +import { + SUPPORT_ATTACHMENT_ACCEPT, + formatSupportAttachmentSize, + normalizeUnknownError, + uploadSupportAttachments, + type StagedSupportAttachment, +} from "@opencom/web-shared"; import { Button, Card, Input } from "@opencom/ui"; import { Ticket, @@ -13,6 +20,8 @@ import { User, MessageSquare, FileText, + Paperclip, + X, } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { AppLayout, AppPageShell } from "@/components/AppLayout"; @@ -51,31 +60,86 @@ function TicketsContent(): React.JSX.Element | null { const [newTicketPriority, setNewTicketPriority] = useState("normal"); const [selectedVisitorId, setSelectedVisitorId] = useState | null>(null); const [visitorSearchQuery, setVisitorSearchQuery] = useState(""); - const { createTicket, recentVisitors, tickets, visitors } = useTicketsPageConvex( + const [pendingAttachments, setPendingAttachments] = useState< + StagedSupportAttachment>[] + >([]); + const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); + const [createTicketError, setCreateTicketError] = useState(null); + const attachmentInputRef = useRef(null); + const { + createTicket, + finalizeSupportAttachmentUpload, + generateSupportAttachmentUploadUrl, + recentVisitors, + tickets, + visitors, + } = useTicketsPageConvex( activeWorkspace?._id, statusFilter === "all" ? undefined : statusFilter, visitorSearchQuery ); + const resetCreateTicketState = () => { + setShowCreateModal(false); + setNewTicketSubject(""); + setNewTicketDescription(""); + setNewTicketPriority("normal"); + setSelectedVisitorId(null); + setVisitorSearchQuery(""); + setPendingAttachments([]); + setCreateTicketError(null); + }; + + const handleUploadAttachments = async (files: File[]) => { + if (!activeWorkspace?._id || files.length === 0) { + return; + } + + setCreateTicketError(null); + setIsUploadingAttachments(true); + try { + const uploadedAttachments = await uploadSupportAttachments({ + files, + currentCount: pendingAttachments.length, + workspaceId: activeWorkspace._id, + generateUploadUrl: generateSupportAttachmentUploadUrl, + finalizeUpload: finalizeSupportAttachmentUpload, + }); + setPendingAttachments((current) => [...current, ...uploadedAttachments]); + } catch (error) { + setCreateTicketError( + normalizeUnknownError(error, { + fallbackMessage: "Failed to upload attachment.", + nextAction: "Try again with a supported file.", + }).message + ); + } finally { + setIsUploadingAttachments(false); + } + }; + const handleCreateTicket = async () => { if (!activeWorkspace?._id || !newTicketSubject.trim()) return; try { + setCreateTicketError(null); await createTicket({ workspaceId: activeWorkspace._id, subject: newTicketSubject.trim(), description: newTicketDescription.trim() || undefined, priority: newTicketPriority, visitorId: selectedVisitorId || undefined, + attachmentIds: pendingAttachments.map((attachment) => attachment.attachmentId), }); - setShowCreateModal(false); - setNewTicketSubject(""); - setNewTicketDescription(""); - setNewTicketPriority("normal"); - setSelectedVisitorId(null); - setVisitorSearchQuery(""); + resetCreateTicketState(); } catch (error) { console.error("Failed to create ticket:", error); + setCreateTicketError( + normalizeUnknownError(error, { + fallbackMessage: "Failed to create ticket.", + nextAction: "Review the details and try again.", + }).message + ); } }; @@ -119,7 +183,12 @@ function TicketsContent(): React.JSX.Element | null { Manage Forms - @@ -251,7 +320,26 @@ function TicketsContent(): React.JSX.Element | null {

Create New Ticket

+ { + const files = Array.from(event.target.files ?? []); + if (files.length > 0) { + void handleUploadAttachments(files); + } + event.target.value = ""; + }} + />
+ {createTicketError && ( +
+ {createTicketError} +
+ )}
+
+
+ + +
+ {pendingAttachments.length > 0 ? ( +
+ {pendingAttachments.map((attachment) => ( +
+ + {attachment.fileName} + + {formatSupportAttachmentSize(attachment.size)} + + +
+ ))} +
+ ) : ( +

+ Add screenshots, PDFs, or logs to help support triage the issue faster. +

+ )} +
- -
diff --git a/apps/widget/src/Widget.tsx b/apps/widget/src/Widget.tsx index 6240352..d3323eb 100644 --- a/apps/widget/src/Widget.tsx +++ b/apps/widget/src/Widget.tsx @@ -436,6 +436,9 @@ export function Widget({ }); const { + commentAttachments, + createTicketAttachments, + isUploadingAttachments, visitorTickets, selectedTicket, ticketForm, @@ -446,6 +449,10 @@ export function Widget({ handleSelectTicket, handleSubmitTicket, handleAddTicketComment, + removeCommentAttachment, + removeCreateTicketAttachment, + uploadCommentAttachments, + uploadCreateTicketAttachments, } = useWidgetTicketFlow({ activeWorkspaceId, isValidIdFormat, @@ -1191,6 +1198,10 @@ export function Widget({ onClose={handleCloseWidget} onSubmit={handleSubmitTicket} isSubmitting={isSubmittingTicket} + isUploadingAttachments={isUploadingAttachments} + pendingAttachments={createTicketAttachments} + onUploadAttachments={uploadCreateTicketAttachments} + onRemoveAttachment={removeCreateTicketAttachment} errorFeedback={ticketErrorFeedback} /> )} @@ -1200,6 +1211,11 @@ export function Widget({ onBack={handleBackFromTickets} onClose={handleCloseWidget} onAddComment={handleAddTicketComment} + onUploadAttachments={uploadCommentAttachments} + onRemoveAttachment={removeCommentAttachment} + pendingAttachments={commentAttachments} + isUploadingAttachments={isUploadingAttachments} + errorFeedback={ticketErrorFeedback} /> )} diff --git a/apps/widget/src/components/ConversationView.tsx b/apps/widget/src/components/ConversationView.tsx index 3d96fa2..000cdd4 100644 --- a/apps/widget/src/components/ConversationView.tsx +++ b/apps/widget/src/components/ConversationView.tsx @@ -1,5 +1,10 @@ import { useState, useEffect, useMemo, useRef } from "react"; import type { Id } from "@opencom/convex/dataModel"; +import { + normalizeUnknownError, + uploadSupportAttachments, + type StagedSupportAttachment, +} from "@opencom/web-shared"; import { ChevronLeft, X, User } from "../icons"; import { useDebouncedValue } from "../hooks/useDebouncedValue"; import { useConversationViewConvex } from "../hooks/convex/useConversationViewConvex"; @@ -46,6 +51,11 @@ export function ConversationView({ const [showArticleSuggestions, setShowArticleSuggestions] = useState(false); const [articleSuggestions, setArticleSuggestions] = useState([]); const [, setIsLoadingSuggestions] = useState(false); + const [composerError, setComposerError] = useState(null); + const [pendingAttachments, setPendingAttachments] = useState< + StagedSupportAttachment>[] + >([]); + const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); const [csatPromptVisible, setCsatPromptVisible] = useState(false); const [dismissedCsatByConversation, setDismissedCsatByConversation] = useState< Record @@ -63,7 +73,9 @@ export function ConversationView({ aiSettings, conversationData, csatEligibility, + finalizeSupportAttachmentUpload, generateAiResponse, + generateSupportAttachmentUploadUrl, handoffToHuman, identifyVisitor, messages, @@ -93,6 +105,10 @@ export function ConversationView({ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); + useEffect(() => { + setPendingAttachments([]); + }, [conversationId]); + useEffect(() => { const dismissed = dismissedCsatByConversation[conversationKey]; const eligible = shouldEvaluateCsat && !!csatEligibility?.eligible && !dismissed; @@ -184,7 +200,7 @@ export function ConversationView({ const sendMessage = async () => { if ( - !inputValue.trim() || + (!inputValue.trim() && pendingAttachments.length === 0) || !conversationId || !visitorId || !activeWorkspaceId || @@ -197,14 +213,17 @@ export function ConversationView({ setInputValue(""); try { + setComposerError(null); await sendMessageMutation({ conversationId, senderId: visitorId, senderType: "visitor", content, + attachmentIds: pendingAttachments.map((attachment) => attachment.attachmentId), visitorId, sessionToken: sessionTokenRef.current ?? undefined, }); + setPendingAttachments([]); // if (!hasVisitorSentMessage) { // setHasVisitorSentMessage(true); // } @@ -245,6 +264,42 @@ export function ConversationView({ } catch (error) { console.error("Failed to send message:", error); setInputValue(content); + setComposerError( + normalizeUnknownError(error, { + fallbackMessage: "Failed to send message.", + nextAction: "Please try again.", + }).message + ); + } + }; + + const handleUploadAttachments = async (files: File[]) => { + if (!activeWorkspaceId || !visitorId || files.length === 0) { + return; + } + + setIsUploadingAttachments(true); + try { + setComposerError(null); + const uploadedAttachments = await uploadSupportAttachments({ + files, + currentCount: pendingAttachments.length, + workspaceId: activeWorkspaceId as Id<"workspaces">, + visitorId, + sessionToken: sessionTokenRef.current ?? undefined, + generateUploadUrl: generateSupportAttachmentUploadUrl, + finalizeUpload: finalizeSupportAttachmentUpload, + }); + setPendingAttachments((current) => [...current, ...uploadedAttachments]); + } catch (error) { + const normalizedError = normalizeUnknownError(error, { + fallbackMessage: "Failed to upload attachment.", + nextAction: "Try again with a supported file.", + }); + console.error("Failed to upload widget attachment:", normalizedError.message); + setComposerError(normalizedError.message); + } finally { + setIsUploadingAttachments(false); } }; @@ -470,9 +525,22 @@ export function ConversationView({ setShowArticleSuggestions(false); }} inputValue={inputValue} - onInputChange={setInputValue} + composerError={composerError} + pendingAttachments={pendingAttachments} + isUploadingAttachments={isUploadingAttachments} + onInputChange={(value) => { + setComposerError(null); + setInputValue(value); + }} onInputKeyDown={handleKeyDown} onSendMessage={sendMessage} + onUploadAttachments={handleUploadAttachments} + onRemovePendingAttachment={(attachmentId) => { + setComposerError(null); + setPendingAttachments((current) => + current.filter((attachment) => attachment.attachmentId !== attachmentId) + ); + }} />
); diff --git a/apps/widget/src/components/TicketCreate.tsx b/apps/widget/src/components/TicketCreate.tsx index fb75c0c..c472ed5 100644 --- a/apps/widget/src/components/TicketCreate.tsx +++ b/apps/widget/src/components/TicketCreate.tsx @@ -1,6 +1,12 @@ -import { useState } from "react"; -import type { ErrorFeedbackMessage } from "@opencom/web-shared"; -import { ChevronLeft, X } from "../icons"; +import { useRef, useState } from "react"; +import type { Id } from "@opencom/convex/dataModel"; +import { + SUPPORT_ATTACHMENT_ACCEPT, + formatSupportAttachmentSize, + type ErrorFeedbackMessage, + type StagedSupportAttachment, +} from "@opencom/web-shared"; +import { ChevronLeft, Paperclip, X } from "../icons"; import { ErrorFeedbackBanner } from "./ErrorFeedbackBanner"; type FormField = { @@ -24,6 +30,10 @@ interface TicketCreateProps { onClose: () => void; onSubmit: (formData: Record) => Promise; isSubmitting: boolean; + isUploadingAttachments: boolean; + pendingAttachments: StagedSupportAttachment>[]; + onUploadAttachments: (files: File[]) => Promise | void; + onRemoveAttachment: (attachmentId: Id<"supportAttachments">) => void; errorFeedback: ErrorFeedbackMessage | null; } @@ -50,9 +60,14 @@ export function TicketCreate({ onClose, onSubmit, isSubmitting, + isUploadingAttachments, + pendingAttachments, + onUploadAttachments, + onRemoveAttachment, errorFeedback, }: TicketCreateProps) { const [formData, setFormData] = useState>({}); + const attachmentInputRef = useRef(null); const formFields = ((ticketForm?.fields?.length ? ticketForm.fields : fallbackFields) || []) as FormField[]; @@ -75,6 +90,20 @@ export function TicketCreate({
+ { + const files = Array.from(event.target.files ?? []); + if (files.length > 0) { + void onUploadAttachments(files); + } + event.target.value = ""; + }} + /> {errorFeedback && } {ticketForm?.description && (

{ticketForm.description}

@@ -161,9 +190,42 @@ export function TicketCreate({
))} +
+ + {pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map((attachment) => ( +
+ + + {attachment.fileName} + + + {formatSupportAttachmentSize(attachment.size)} + + +
+ ))} +
+ )} +
+ + {pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map((attachment) => ( +
+ + + {attachment.fileName} + + + {formatSupportAttachmentSize(attachment.size)} + + +
+ ))} +
+ )} - diff --git a/apps/widget/src/components/conversationView/Footer.test.tsx b/apps/widget/src/components/conversationView/Footer.test.tsx new file mode 100644 index 0000000..6549f74 --- /dev/null +++ b/apps/widget/src/components/conversationView/Footer.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { Id } from "@opencom/convex/dataModel"; +import { ConversationFooter } from "./Footer"; + +describe("ConversationFooter", () => { + it("keeps the composer controls together when pending attachments are present", () => { + const { container } = render( + } + visitorId={"visitor-1" as Id<"visitors">} + sessionToken={undefined} + csatPromptVisible={false} + shouldEvaluateCsat={false} + onDismissCsatPrompt={vi.fn()} + onCsatSubmitted={vi.fn()} + isConversationResolved={false} + automationSettings={undefined} + csatEligibility={undefined} + showEmailCapture={false} + emailInput="" + onEmailInputChange={vi.fn()} + onEmailSubmit={vi.fn()} + onEmailDismiss={vi.fn()} + officeHoursStatus={undefined} + expectedReplyTime={undefined} + commonIssueButtons={[]} + hasMessages + onSelectArticle={vi.fn()} + onApplyConversationStarter={vi.fn()} + showArticleSuggestions={false} + articleSuggestions={[]} + onSelectSuggestionArticle={vi.fn()} + inputValue="" + composerError={null} + pendingAttachments={[ + { + attachmentId: "attachment-1" as Id<"supportAttachments">, + fileName: "screenshot-one.png", + mimeType: "image/png", + size: 1024, + status: "staged", + }, + { + attachmentId: "attachment-2" as Id<"supportAttachments">, + fileName: "screenshot-two.png", + mimeType: "image/png", + size: 2048, + status: "staged", + }, + ]} + isUploadingAttachments={false} + onInputChange={vi.fn()} + onInputKeyDown={vi.fn()} + onSendMessage={vi.fn()} + onUploadAttachments={vi.fn()} + onRemovePendingAttachment={vi.fn()} + /> + ); + + const composerRow = container.querySelector(".opencom-composer-row"); + const pendingList = container.querySelector(".opencom-pending-attachments"); + const attachButton = screen.getByLabelText("Attach files"); + const messageInput = screen.getByTestId("widget-message-input"); + const sendButton = screen.getByTestId("widget-send-button"); + const attachmentName = screen.getByText("screenshot-one.png"); + + expect(composerRow).toBeTruthy(); + expect(pendingList).toBeTruthy(); + expect(composerRow).toContainElement(attachButton); + expect(composerRow).toContainElement(messageInput); + expect(composerRow).toContainElement(sendButton); + expect(composerRow).not.toContainElement(attachmentName); + expect(pendingList).toContainElement(attachmentName); + }); +}); diff --git a/apps/widget/src/components/conversationView/Footer.tsx b/apps/widget/src/components/conversationView/Footer.tsx index de4816d..6fe3bfe 100644 --- a/apps/widget/src/components/conversationView/Footer.tsx +++ b/apps/widget/src/components/conversationView/Footer.tsx @@ -1,6 +1,11 @@ -import type { KeyboardEvent } from "react"; +import { useRef, type KeyboardEvent } from "react"; import type { Id } from "@opencom/convex/dataModel"; -import { Book, Send } from "../../icons"; +import { + SUPPORT_ATTACHMENT_ACCEPT, + formatSupportAttachmentSize, + type StagedSupportAttachment, +} from "@opencom/web-shared"; +import { Book, Paperclip, Send, X } from "../../icons"; import { CsatPrompt } from "../../CsatPrompt"; import type { ArticleSuggestion, ConversationViewProps, CsatEligibility } from "./types"; @@ -30,9 +35,14 @@ interface ConversationFooterProps { articleSuggestions: ArticleSuggestion[]; onSelectSuggestionArticle: (id: string) => void; inputValue: string; + composerError?: string | null; + pendingAttachments: StagedSupportAttachment>[]; + isUploadingAttachments: boolean; onInputChange: (value: string) => void; onInputKeyDown: (e: KeyboardEvent) => void; onSendMessage: () => void | Promise; + onUploadAttachments: (files: File[]) => void | Promise; + onRemovePendingAttachment: (attachmentId: Id<"supportAttachments">) => void; } export function ConversationFooter({ @@ -61,10 +71,18 @@ export function ConversationFooter({ articleSuggestions, onSelectSuggestionArticle, inputValue, + composerError, + pendingAttachments, + isUploadingAttachments, onInputChange, onInputKeyDown, onSendMessage, + onUploadAttachments, + onRemovePendingAttachment, }: ConversationFooterProps) { + const attachmentInputRef = useRef(null); + const canSendMessage = inputValue.trim().length > 0 || pendingAttachments.length > 0; + return (
{csatPromptVisible && shouldEvaluateCsat && ( @@ -194,26 +212,75 @@ export function ConversationFooter({ )}
+ {composerError &&
{composerError}
} onInputChange(e.target.value)} - onKeyDown={onInputKeyDown} - placeholder="Type a message..." - className="opencom-input" - data-testid="widget-message-input" - /> - + /> + {pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map((attachment) => ( +
+ + + {attachment.fileName} + + + {formatSupportAttachmentSize(attachment.size)} + + +
+ ))} +
+ )} +
+ + onInputChange(e.target.value)} + onKeyDown={onInputKeyDown} + placeholder="Type a message..." + className="opencom-input" + data-testid="widget-message-input" + disabled={isUploadingAttachments} + /> + +
)} diff --git a/apps/widget/src/components/conversationView/MessageList.test.tsx b/apps/widget/src/components/conversationView/MessageList.test.tsx new file mode 100644 index 0000000..580687d --- /dev/null +++ b/apps/widget/src/components/conversationView/MessageList.test.tsx @@ -0,0 +1,45 @@ +import { createRef } from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ConversationMessageList } from "./MessageList"; + +describe("ConversationMessageList attachments", () => { + it("renders disabled attachment rows when signed URLs are unavailable", () => { + render( + false} + getAiResponseData={() => undefined} + aiResponseFeedback={{}} + onAiFeedback={vi.fn()} + onSelectArticle={vi.fn()} + showWaitingForHumanSupport={false} + isAiTyping={false} + renderedMessages={new Map()} + messagesEndRef={createRef()} + /> + ); + + expect(screen.getByText("missing-url.txt")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /missing-url\.txt/i })).not.toBeInTheDocument(); + expect(screen.getByText("missing-url.txt").closest("[aria-disabled='true']")).toBeTruthy(); + }); +}); diff --git a/apps/widget/src/components/conversationView/MessageList.tsx b/apps/widget/src/components/conversationView/MessageList.tsx index db77926..7d39dd2 100644 --- a/apps/widget/src/components/conversationView/MessageList.tsx +++ b/apps/widget/src/components/conversationView/MessageList.tsx @@ -1,7 +1,7 @@ import type { RefObject } from "react"; import type { Id } from "@opencom/convex/dataModel"; -import { resolveArticleSourceId } from "@opencom/web-shared"; -import { Bot, ThumbsUp, ThumbsDown, User } from "../../icons"; +import { formatSupportAttachmentSize, resolveArticleSourceId } from "@opencom/web-shared"; +import { Bot, Paperclip, ThumbsUp, ThumbsDown, User } from "../../icons"; import { formatTime } from "../../utils/format"; import { resolveHumanAgentName } from "./helpers"; import type { AiFeedback, AiResponseData, ConversationMessage } from "./types"; @@ -20,6 +20,44 @@ interface ConversationMessageListProps { messagesEndRef: RefObject; } +function renderAttachmentRow(attachment: NonNullable[number]) { + const content = ( + <> + + + {attachment.fileName} + + + {formatSupportAttachmentSize(attachment.size)} + + + ); + + if (attachment.url) { + return ( + + {content} + + ); + } + + return ( +
+ {content} +
+ ); +} + export function ConversationMessageList({ messages, aiSettingsEnabled, @@ -81,12 +119,19 @@ export function ConversationMessageList({ {humanAgentName} )} -
+ {msg.content.trim().length > 0 && ( +
+ )} + {msg.attachments && msg.attachments.length > 0 && ( +
+ {msg.attachments.map((attachment) => renderAttachmentRow(attachment))} +
+ )} {isAi && aiData && (
{aiData.sources && aiData.sources.length > 0 && ( diff --git a/apps/widget/src/components/conversationView/types.ts b/apps/widget/src/components/conversationView/types.ts index da1c517..9a81e74 100644 --- a/apps/widget/src/components/conversationView/types.ts +++ b/apps/widget/src/components/conversationView/types.ts @@ -1,4 +1,5 @@ import type { Id } from "@opencom/convex/dataModel"; +import type { SupportAttachmentDescriptor } from "@opencom/web-shared"; export interface ConversationViewProps { conversationId: Id<"conversations">; @@ -40,6 +41,7 @@ export interface ConversationMessage { senderId: string; content: string; senderName?: string; + attachments?: SupportAttachmentDescriptor[]; } export interface AiResponseSource { diff --git a/apps/widget/src/hooks/convex/useConversationViewConvex.ts b/apps/widget/src/hooks/convex/useConversationViewConvex.ts index 111ba22..95f6ac3 100644 --- a/apps/widget/src/hooks/convex/useConversationViewConvex.ts +++ b/apps/widget/src/hooks/convex/useConversationViewConvex.ts @@ -1,4 +1,5 @@ import type { Id } from "@opencom/convex/dataModel"; +import type { SupportAttachmentFinalizeResult } from "@opencom/web-shared"; import { useWidgetAction, useWidgetMutation, @@ -65,10 +66,22 @@ type SendMessageArgs = { senderId: Id<"visitors">; senderType: "visitor"; content: string; + attachmentIds?: Id<"supportAttachments">[]; visitorId: Id<"visitors">; sessionToken?: string; }; +type SupportAttachmentUploadArgs = { + workspaceId: Id<"workspaces">; + visitorId?: Id<"visitors">; + sessionToken?: string; +}; + +type FinalizeSupportAttachmentUploadArgs = SupportAttachmentUploadArgs & { + storageId: Id<"_storage">; + fileName?: string; +}; + type GenerateAiResponseArgs = { workspaceId: Id<"workspaces">; conversationId: Id<"conversations">; @@ -112,6 +125,14 @@ type ConversationViewConvexOptions = { }; const SEND_MESSAGE_MUTATION_REF = widgetMutationRef("messages:send"); +const GENERATE_SUPPORT_ATTACHMENT_UPLOAD_URL_REF = widgetMutationRef< + SupportAttachmentUploadArgs, + string +>("supportAttachments:generateUploadUrl"); +const FINALIZE_SUPPORT_ATTACHMENT_UPLOAD_REF = widgetMutationRef< + FinalizeSupportAttachmentUploadArgs, + SupportAttachmentFinalizeResult> +>("supportAttachments:finalizeUpload"); const IDENTIFY_VISITOR_MUTATION_REF = widgetMutationRef( "visitors:identify" ); @@ -156,6 +177,12 @@ export function useConversationViewConvex({ shouldEvaluateCsat, }: ConversationViewConvexOptions) { const sendMessageMutation = useWidgetMutation(SEND_MESSAGE_MUTATION_REF); + const generateSupportAttachmentUploadUrl = useWidgetMutation( + GENERATE_SUPPORT_ATTACHMENT_UPLOAD_URL_REF + ); + const finalizeSupportAttachmentUpload = useWidgetMutation( + FINALIZE_SUPPORT_ATTACHMENT_UPLOAD_REF + ); const identifyVisitor = useWidgetMutation(IDENTIFY_VISITOR_MUTATION_REF); const generateAiResponse = useWidgetAction(GENERATE_AI_RESPONSE_ACTION_REF); const submitAiFeedback = useWidgetMutation(SUBMIT_AI_FEEDBACK_MUTATION_REF); @@ -199,7 +226,9 @@ export function useConversationViewConvex({ aiSettings, conversationData, csatEligibility, + finalizeSupportAttachmentUpload, generateAiResponse, + generateSupportAttachmentUploadUrl, handoffToHuman, identifyVisitor, messages, diff --git a/apps/widget/src/hooks/useWidgetTicketFlow.ts b/apps/widget/src/hooks/useWidgetTicketFlow.ts index abf86be..032fe0d 100644 --- a/apps/widget/src/hooks/useWidgetTicketFlow.ts +++ b/apps/widget/src/hooks/useWidgetTicketFlow.ts @@ -1,6 +1,13 @@ import { useCallback, useState, type MutableRefObject } from "react"; import type { Id } from "@opencom/convex/dataModel"; -import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared"; +import { + normalizeUnknownError, + uploadSupportAttachments, + type ErrorFeedbackMessage, + type StagedSupportAttachment, + type SupportAttachmentDescriptor, + type SupportAttachmentFinalizeResult, +} from "@opencom/web-shared"; import { normalizeTicketFormData } from "../widgetShell/helpers"; import type { TicketFormData, WidgetView } from "../widgetShell/types"; import { @@ -34,6 +41,7 @@ type VisitorTicketRecord = { subject: string; status: string; createdAt: number; + attachments?: SupportAttachmentDescriptor[]; }; type TicketDetailRecord = { @@ -42,12 +50,14 @@ type TicketDetailRecord = { status: string; description?: string; resolutionSummary?: string; + attachments?: SupportAttachmentDescriptor[]; comments?: Array<{ _id: string; authorType: string; content: string; createdAt: number; isInternal: boolean; + attachments?: SupportAttachmentDescriptor[]; }>; }; @@ -73,7 +83,13 @@ const defaultTicketFormQueryRef = widgetQueryRef< >("ticketForms:getDefaultForVisitor"); const addTicketCommentMutationRef = widgetMutationRef< - { ticketId: Id<"tickets">; visitorId: Id<"visitors">; content: string; sessionToken?: string }, + { + ticketId: Id<"tickets">; + visitorId: Id<"visitors">; + content: string; + attachmentIds?: Id<"supportAttachments">[]; + sessionToken?: string; + }, null >("tickets:addComment"); @@ -84,12 +100,33 @@ const createTicketMutationRef = widgetMutationRef< sessionToken?: string; subject: string; description?: string; + attachmentIds?: Id<"supportAttachments">[]; formId?: string; formData: TicketFormData; }, Id<"tickets"> >("tickets:create"); +const generateSupportAttachmentUploadUrlRef = widgetMutationRef< + { + workspaceId: Id<"workspaces">; + visitorId?: Id<"visitors">; + sessionToken?: string; + }, + string +>("supportAttachments:generateUploadUrl"); + +const finalizeSupportAttachmentUploadRef = widgetMutationRef< + { + workspaceId: Id<"workspaces">; + visitorId?: Id<"visitors">; + sessionToken?: string; + storageId: Id<"_storage">; + fileName?: string; + }, + SupportAttachmentFinalizeResult> +>("supportAttachments:finalizeUpload"); + export function useWidgetTicketFlow({ activeWorkspaceId, isValidIdFormat, @@ -102,6 +139,13 @@ export function useWidgetTicketFlow({ const [selectedTicketId, setSelectedTicketId] = useState | null>(null); const [isSubmittingTicket, setIsSubmittingTicket] = useState(false); const [ticketErrorFeedback, setTicketErrorFeedback] = useState(null); + const [createTicketAttachments, setCreateTicketAttachments] = useState< + StagedSupportAttachment>[] + >([]); + const [commentAttachments, setCommentAttachments] = useState< + StagedSupportAttachment>[] + >([]); + const [isUploadingAttachments, setIsUploadingAttachments] = useState(false); const visitorTickets = useWidgetQuery( visitorTicketsQueryRef, @@ -128,10 +172,17 @@ export function useWidgetTicketFlow({ const addTicketComment = useWidgetMutation(addTicketCommentMutationRef); const createTicket = useWidgetMutation(createTicketMutationRef); + const generateSupportAttachmentUploadUrl = useWidgetMutation( + generateSupportAttachmentUploadUrlRef + ); + const finalizeSupportAttachmentUpload = useWidgetMutation( + finalizeSupportAttachmentUploadRef + ); const handleBackFromTickets = useCallback(() => { setTicketErrorFeedback(null); setSelectedTicketId(null); + setCommentAttachments([]); onTabChange("tickets"); onViewChange("conversation-list"); }, [onTabChange, onViewChange]); @@ -139,11 +190,13 @@ export function useWidgetTicketFlow({ const openTicketCreate = useCallback(() => { setTicketErrorFeedback(null); setSelectedTicketId(null); + setCreateTicketAttachments([]); onViewChange("ticket-create"); }, [onViewChange]); const handleSelectTicket = useCallback( (ticketId: Id<"tickets">) => { + setCommentAttachments([]); setSelectedTicketId(ticketId); onViewChange("ticket-detail"); }, @@ -193,10 +246,12 @@ export function useWidgetTicketFlow({ sessionToken: sessionTokenRef.current ?? undefined, subject: subject.trim(), description: description?.trim(), + attachmentIds: createTicketAttachments.map((attachment) => attachment.attachmentId), formId: ticketForm?._id, formData: normalizeTicketFormData(formData) as TicketFormData, }); setSelectedTicketId(ticketId); + setCreateTicketAttachments([]); onViewChange("ticket-detail"); } catch (error) { console.error("Failed to create ticket:", error); @@ -212,6 +267,7 @@ export function useWidgetTicketFlow({ }, [ activeWorkspaceId, + createTicketAttachments, createTicket, isSubmittingTicket, onViewChange, @@ -225,20 +281,80 @@ export function useWidgetTicketFlow({ async (content: string) => { if (!selectedTicketId || !visitorId) return; try { + setTicketErrorFeedback(null); await addTicketComment({ ticketId: selectedTicketId, visitorId, content, + attachmentIds: commentAttachments.map((attachment) => attachment.attachmentId), sessionToken: sessionTokenRef.current ?? undefined, }); + setCommentAttachments([]); } catch (error) { console.error("Failed to add comment:", error); + setTicketErrorFeedback( + normalizeUnknownError(error, { + fallbackMessage: "Failed to add reply.", + nextAction: "Please try again.", + }) + ); + throw error; + } + }, + [addTicketComment, commentAttachments, selectedTicketId, sessionTokenRef, visitorId] + ); + + const uploadAttachments = useCallback( + async (target: "create" | "comment", files: File[]) => { + if (!visitorId || !activeWorkspaceId || files.length === 0) { + return; + } + + const currentAttachments = + target === "create" ? createTicketAttachments : commentAttachments; + setTicketErrorFeedback(null); + setIsUploadingAttachments(true); + try { + const uploadedAttachments = await uploadSupportAttachments({ + files, + currentCount: currentAttachments.length, + workspaceId: activeWorkspaceId as Id<"workspaces">, + visitorId, + sessionToken: sessionTokenRef.current ?? undefined, + generateUploadUrl: generateSupportAttachmentUploadUrl, + finalizeUpload: finalizeSupportAttachmentUpload, + }); + if (target === "create") { + setCreateTicketAttachments((current) => [...current, ...uploadedAttachments]); + } else { + setCommentAttachments((current) => [...current, ...uploadedAttachments]); + } + } catch (error) { + setTicketErrorFeedback( + normalizeUnknownError(error, { + fallbackMessage: "Failed to upload attachment.", + nextAction: "Try again with a supported file.", + }) + ); + } finally { + setIsUploadingAttachments(false); } }, - [addTicketComment, selectedTicketId, sessionTokenRef, visitorId] + [ + activeWorkspaceId, + commentAttachments, + createTicketAttachments, + finalizeSupportAttachmentUpload, + generateSupportAttachmentUploadUrl, + sessionTokenRef, + visitorId, + ] ); return { + commentAttachments, + createTicketAttachments, + isUploadingAttachments, visitorTickets, selectedTicket, ticketForm, @@ -249,5 +365,15 @@ export function useWidgetTicketFlow({ handleSelectTicket, handleSubmitTicket, handleAddTicketComment, + removeCommentAttachment: (attachmentId: Id<"supportAttachments">) => + setCommentAttachments((current) => + current.filter((attachment) => attachment.attachmentId !== attachmentId) + ), + removeCreateTicketAttachment: (attachmentId: Id<"supportAttachments">) => + setCreateTicketAttachments((current) => + current.filter((attachment) => attachment.attachmentId !== attachmentId) + ), + uploadCommentAttachments: (files: File[]) => uploadAttachments("comment", files), + uploadCreateTicketAttachments: (files: File[]) => uploadAttachments("create", files), }; } diff --git a/apps/widget/src/icons.tsx b/apps/widget/src/icons.tsx index 37c4d49..fc4337f 100644 --- a/apps/widget/src/icons.tsx +++ b/apps/widget/src/icons.tsx @@ -54,6 +54,24 @@ export function Send() { ); } +export function Paperclip() { + return ( + + + + ); +} + export function ChevronLeft() { return ( Mitigation: make picker availability explicit in config/context and preserve a stable text-only fallback. + +- [Risk] React Native local-file upload mechanics differ across environments. + -> Mitigation: normalize picked attachments through one RN helper layer and cover the Expo-backed path used by first-party mobile. + +- [Risk] Attachment chips and composer queues can crowd small-screen layouts. + -> Mitigation: keep the native UI compact, cap visible queued items, and prefer scrolling attachment stacks over expanding the composer indefinitely. + +- [Risk] Extending public hook and component contracts can create subtle SDK compatibility drift. + -> Mitigation: keep changes additive where possible and update contract tests alongside implementation. + +## Migration Plan + +1. Add RN attachment types, picker adapter contracts, and upload helpers in `packages/react-native-sdk`. +2. Extend RN SDK conversation/ticket hooks and controller layers to upload staged attachments, send attachment IDs, and read attachment descriptors. +3. Update RN SDK messenger and ticket UI components to queue, render, and remove attachments. +4. Extend `apps/mobile` conversation wrappers/types and wire an Expo-backed picker plus attachment rendering into the existing agent conversation screen. +5. Add or update RN SDK and mobile hardening/UI tests. +6. Validate the change with strict OpenSpec validation before implementation handoff. + +Rollback strategy: + +- Remove or disable native attachment affordances in RN/mobile UI while leaving the backend support attachment model intact. +- Keep additive read fields and attachment-aware types backward-compatible where possible so historical attachment-bearing content remains readable. +- If needed, revert picker adapter wiring independently from backend attachment support. + +## Open Questions + +- Should the RN SDK expose a default Expo picker adapter when `expo-document-picker` is installed, or require explicit host injection in all cases? +- Do we want to extend the imperative `OpencomSDK` API for attachment-bearing sends in the same change, or keep v1 scoped to hooks/components? +- What native affordance should we use to open downloaded attachments across platforms: direct URL open, share sheet, or host-provided handler? diff --git a/openspec/changes/add-mobile-and-rn-sdk-file-uploads/proposal.md b/openspec/changes/add-mobile-and-rn-sdk-file-uploads/proposal.md new file mode 100644 index 0000000..53b1ba1 --- /dev/null +++ b/openspec/changes/add-mobile-and-rn-sdk-file-uploads/proposal.md @@ -0,0 +1,26 @@ +## Why + +Support attachment uploads now exist for web inbox, web tickets, and the widget, but the React Native SDK and first-party mobile app remain text-only. That creates an inconsistent support experience across surfaces and blocks common mobile workflows like sending screenshots, PDFs, and logs directly from a device. + +## What Changes + +- Add file attachment upload, validation, send, and rendering flows to the React Native SDK messenger and ticket surfaces. +- Extend the React Native SDK transport and component contracts to work with staged support attachments, secure download URLs, and attachment metadata returned from Convex. +- Add native attachment support to the first-party mobile app's existing agent conversation screen so mobile responders can send and review files from inbox conversations. +- Introduce shared React Native attachment helpers for file picking, upload orchestration, and attachment presentation that reuse the existing Convex support-attachment backend model. +- Add hardening and regression coverage for React Native/mobile attachment boundaries, validation failures, and authorized rendering. + +## Capabilities + +### New Capabilities +- `mobile-and-rn-sdk-file-attachments`: Upload, send, and render support attachments across the React Native SDK and the first-party mobile app. + +### Modified Capabilities +- None. + +## Impact + +- `packages/react-native-sdk` hooks, components, transport adapters, and tests for messenger and ticket attachment flows. +- `apps/mobile` conversation experience, Convex wrapper hooks/types, and mobile hardening coverage for agent-side attachments. +- Shared upload helper code and any React Native file-picker integration needed to bridge device files into the existing Convex support attachment pipeline. +- Validation, boundary, and UI regression tests to keep React Native/mobile attachment behavior aligned with the web/widget attachment model. diff --git a/openspec/changes/add-mobile-and-rn-sdk-file-uploads/specs/mobile-and-rn-sdk-file-attachments/spec.md b/openspec/changes/add-mobile-and-rn-sdk-file-uploads/specs/mobile-and-rn-sdk-file-attachments/spec.md new file mode 100644 index 0000000..449930c --- /dev/null +++ b/openspec/changes/add-mobile-and-rn-sdk-file-uploads/specs/mobile-and-rn-sdk-file-attachments/spec.md @@ -0,0 +1,77 @@ +## ADDED Requirements + +### Requirement: React Native SDK messenger conversations MUST support attachments + +The system MUST allow React Native SDK messenger users to select supported files, upload them through the existing support-attachment backend flow, send them with a conversation message, and view them again in conversation history. + +#### Scenario: Visitor sends a messenger message with attachments in the RN SDK + +- **WHEN** a visitor selects one or more supported files in the React Native SDK messenger composer and sends the message +- **THEN** the system SHALL upload and stage those files before binding them to the outgoing conversation message +- **AND** subsequent messenger reads SHALL include the message attachments with secure download URLs for authorized viewers + +#### Scenario: Messenger upload validation fails in the RN SDK + +- **WHEN** the visitor selects an unsupported file, exceeds attachment limits, or the staged upload is rejected +- **THEN** the React Native SDK SHALL present actionable error feedback in the messenger flow +- **AND** the system SHALL NOT send the message with invalid attachments + +#### Scenario: Existing messenger attachments are rendered in conversation history + +- **WHEN** the React Native SDK opens a conversation that already contains message attachments +- **THEN** the conversation view SHALL render those attachments with filename and size metadata +- **AND** authorized users SHALL be able to open the secure attachment link from the native client + +### Requirement: React Native SDK tickets MUST support attachments on creation and replies + +The system MUST allow React Native SDK ticket users to attach supported files during ticket submission and while adding ticket comments, and MUST render those attachments in ticket detail. + +#### Scenario: Visitor creates a ticket with attachments in the RN SDK + +- **WHEN** a visitor selects supported files while submitting a ticket from the React Native SDK +- **THEN** the system SHALL create the ticket and associate the uploaded files with that ticket submission +- **AND** ticket detail reads SHALL return those ticket-level attachments for authorized viewers + +#### Scenario: Visitor replies to a ticket with attachments in the RN SDK + +- **WHEN** a visitor selects supported files while adding a ticket comment from the React Native SDK +- **THEN** the system SHALL bind those files to the created ticket comment +- **AND** subsequent ticket detail reads SHALL include the comment attachments in the correct timeline position + +#### Scenario: Existing ticket attachments are rendered in the RN SDK + +- **WHEN** the React Native SDK opens a ticket that includes submission or comment attachments +- **THEN** the ticket detail flow SHALL render the attachments for each ticket section where they belong +- **AND** authorized users SHALL be able to open the secure attachment link from the native client + +### Requirement: React Native SDK attachment support MUST remain configurable for host apps + +The React Native SDK MUST provide a host-controlled attachment picking boundary so applications can enable native file selection without breaking text-only messenger and ticket usage where no picker integration is configured. + +#### Scenario: Host app provides an attachment picker integration + +- **WHEN** a host app configures the React Native SDK with an attachment picker implementation +- **THEN** the built-in messenger and ticket compose surfaces SHALL expose attachment selection controls +- **AND** the picked files SHALL flow through the SDK attachment upload pipeline before send or submit + +#### Scenario: Host app does not provide an attachment picker integration + +- **WHEN** the React Native SDK is rendered without an attachment picker integration +- **THEN** the built-in messenger and ticket compose surfaces SHALL continue to support text-only sending +- **AND** the SDK SHALL NOT crash or expose a broken attachment affordance + +### Requirement: First-party mobile agent conversations MUST support attachments + +The first-party mobile app MUST allow agents to send supported files from the existing conversation screen and MUST render attachments returned in conversation history. + +#### Scenario: Agent sends a mobile conversation reply with attachments + +- **WHEN** an authenticated agent selects supported files from the first-party mobile conversation screen and sends a reply +- **THEN** the system SHALL bind those uploaded files to the outgoing conversation message +- **AND** the conversation thread SHALL show the attachments on the newly sent reply + +#### Scenario: Agent views conversation history with attachments on mobile + +- **WHEN** the first-party mobile conversation screen loads messages that include attachments +- **THEN** the screen SHALL render those attachments in the appropriate message bubble or attachment list +- **AND** the agent SHALL be able to open the secure attachment link from the mobile app diff --git a/openspec/changes/add-mobile-and-rn-sdk-file-uploads/tasks.md b/openspec/changes/add-mobile-and-rn-sdk-file-uploads/tasks.md new file mode 100644 index 0000000..444cfb2 --- /dev/null +++ b/openspec/changes/add-mobile-and-rn-sdk-file-uploads/tasks.md @@ -0,0 +1,23 @@ +## 1. RN SDK Attachment Boundaries + +- [ ] 1.1 Add React Native attachment types, picker adapter contracts, and upload helpers that map local device files onto the existing Convex staged-upload flow. +- [ ] 1.2 Extend `packages/react-native-sdk` conversation and ticket hooks/controllers to read attachment descriptors and send attachment IDs for messenger messages, ticket creation, and ticket replies. +- [ ] 1.3 Update RN SDK hardening and contract tests for the new attachment transport boundaries and public component contracts. + +## 2. RN SDK Messenger And Ticket UI + +- [ ] 2.1 Add attachment queueing, upload error feedback, and send controls to the RN SDK messenger conversation experience. +- [ ] 2.2 Add attachment selection, rendering, and removal UX to RN SDK ticket creation. +- [ ] 2.3 Add attachment rendering and reply-upload UX to RN SDK ticket detail. + +## 3. First-Party Mobile Conversation Support + +- [ ] 3.1 Wire an Expo-backed attachment picker into the first-party mobile app and normalize picked files for upload. +- [ ] 3.2 Extend mobile Convex wrapper types and conversation send/read flows to handle attachment descriptors and attachment IDs. +- [ ] 3.3 Update the mobile agent conversation screen to queue, send, render, and open attachments with actionable validation feedback. + +## 4. Verification + +- [ ] 4.1 Add or update RN SDK tests covering attachment queue state, validation failures, and message/ticket attachment rendering. +- [ ] 4.2 Add or update mobile tests and hardening guards for attachment-aware conversation flows. +- [ ] 4.3 Run strict `openspec validate add-mobile-and-rn-sdk-file-uploads --strict --no-interactive` once the change artifacts and implementation are ready. diff --git a/packages/convex/convex/_generated/api.d.ts b/packages/convex/convex/_generated/api.d.ts index f372bec..ee164bd 100644 --- a/packages/convex/convex/_generated/api.d.ts +++ b/packages/convex/convex/_generated/api.d.ts @@ -111,6 +111,7 @@ import type * as schema_operationsReportingTables from "../schema/operationsRepo import type * as schema_operationsTables from "../schema/operationsTables.js"; import type * as schema_operationsWorkflowTables from "../schema/operationsWorkflowTables.js"; import type * as schema_outboundSupportTables from "../schema/outboundSupportTables.js"; +import type * as schema_supportAttachmentTables from "../schema/supportAttachmentTables.js"; import type * as segments from "../segments.js"; import type * as series from "../series.js"; import type * as series_authoring from "../series/authoring.js"; @@ -127,6 +128,9 @@ import type * as series_telemetry from "../series/telemetry.js"; import type * as setup from "../setup.js"; import type * as snippets from "../snippets.js"; import type * as suggestions from "../suggestions.js"; +import type * as supportAttachmentFunctionRefs from "../supportAttachmentFunctionRefs.js"; +import type * as supportAttachmentTypes from "../supportAttachmentTypes.js"; +import type * as supportAttachments from "../supportAttachments.js"; import type * as surveys from "../surveys.js"; import type * as surveys_authoring from "../surveys/authoring.js"; import type * as surveys_delivery from "../surveys/delivery.js"; @@ -147,6 +151,7 @@ import type * as testing_helpers_conversations from "../testing/helpers/conversa import type * as testing_helpers_email from "../testing/helpers/email.js"; import type * as testing_helpers_notifications from "../testing/helpers/notifications.js"; import type * as testing_helpers_series from "../testing/helpers/series.js"; +import type * as testing_helpers_supportAttachments from "../testing/helpers/supportAttachments.js"; import type * as testing_helpers_tickets from "../testing/helpers/tickets.js"; import type * as testing_helpers_workspace from "../testing/helpers/workspace.js"; import type * as ticketForms from "../ticketForms.js"; @@ -291,6 +296,7 @@ declare const fullApi: ApiFromModules<{ "schema/operationsTables": typeof schema_operationsTables; "schema/operationsWorkflowTables": typeof schema_operationsWorkflowTables; "schema/outboundSupportTables": typeof schema_outboundSupportTables; + "schema/supportAttachmentTables": typeof schema_supportAttachmentTables; segments: typeof segments; series: typeof series; "series/authoring": typeof series_authoring; @@ -307,6 +313,9 @@ declare const fullApi: ApiFromModules<{ setup: typeof setup; snippets: typeof snippets; suggestions: typeof suggestions; + supportAttachmentFunctionRefs: typeof supportAttachmentFunctionRefs; + supportAttachmentTypes: typeof supportAttachmentTypes; + supportAttachments: typeof supportAttachments; surveys: typeof surveys; "surveys/authoring": typeof surveys_authoring; "surveys/delivery": typeof surveys_delivery; @@ -327,6 +336,7 @@ declare const fullApi: ApiFromModules<{ "testing/helpers/email": typeof testing_helpers_email; "testing/helpers/notifications": typeof testing_helpers_notifications; "testing/helpers/series": typeof testing_helpers_series; + "testing/helpers/supportAttachments": typeof testing_helpers_supportAttachments; "testing/helpers/tickets": typeof testing_helpers_tickets; "testing/helpers/workspace": typeof testing_helpers_workspace; ticketForms: typeof ticketForms; diff --git a/packages/convex/convex/messages.ts b/packages/convex/convex/messages.ts index 56d27fd..1cfcb6e 100644 --- a/packages/convex/convex/messages.ts +++ b/packages/convex/convex/messages.ts @@ -4,6 +4,14 @@ import { type Doc, type Id } from "./_generated/dataModel"; import { getAuthenticatedUserFromSession } from "./auth"; import { getShallowRunAfter, notifyNewMessageRef } from "./notifications/functionRefs"; import { hasPermission, requirePermission } from "./permissions"; +import { + bindStagedSupportAttachments, + describeSupportAttachmentSelection, + loadSupportAttachmentDescriptorMap, + materializeSupportAttachmentDescriptors, + type SupportAttachmentDescriptor, +} from "./supportAttachments"; +import { supportAttachmentIdArrayValidator } from "./supportAttachmentTypes"; import { resolveVisitorFromSession } from "./widgetSessions"; async function withSupportSenderNames( @@ -53,6 +61,21 @@ async function withSupportSenderNames( }); } +async function withMessageAttachments( + ctx: QueryCtx, + messages: Array & { senderName?: string }> +): Promise & { senderName?: string; attachments: SupportAttachmentDescriptor[] }>> { + const descriptorMap = await loadSupportAttachmentDescriptorMap( + ctx, + messages.flatMap((message) => message.attachmentIds ?? []) + ); + + return messages.map((message) => ({ + ...message, + attachments: materializeSupportAttachmentDescriptors(message.attachmentIds, descriptorMap), + })); +} + export const list = query({ args: { conversationId: v.id("conversations"), @@ -88,7 +111,7 @@ export const list = query({ .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)) .order("asc") .collect(); - return await withSupportSenderNames(ctx, messages); + return await withMessageAttachments(ctx, await withSupportSenderNames(ctx, messages)); } } @@ -113,7 +136,7 @@ export const list = query({ .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)) .order("asc") .collect(); - return await withSupportSenderNames(ctx, messages); + return await withMessageAttachments(ctx, await withSupportSenderNames(ctx, messages)); }, }); @@ -128,6 +151,7 @@ export const send = mutation({ v.literal("bot") ), content: v.string(), + attachmentIds: v.optional(supportAttachmentIdArrayValidator), visitorId: v.optional(v.id("visitors")), sessionToken: v.optional(v.string()), }, @@ -143,8 +167,9 @@ export const send = mutation({ } // Authorization: visitors can only send to their own conversations + let resolvedVisitorId = args.visitorId; + let authenticatedUserId: Id<"users"> | undefined; if (args.senderType === "visitor") { - let resolvedVisitorId = args.visitorId; if (args.sessionToken) { const resolved = await resolveVisitorFromSession(ctx, { sessionToken: args.sessionToken, @@ -162,6 +187,7 @@ export const send = mutation({ throw new Error("Not authenticated"); } await requirePermission(ctx, user._id, conversation.workspaceId, "conversations.reply"); + authenticatedUserId = user._id; } const now = Date.now(); @@ -174,6 +200,29 @@ export const send = mutation({ createdAt: now, }); + let attachedIds: Id<"supportAttachments">[] = []; + if (args.senderType === "visitor" && resolvedVisitorId) { + attachedIds = await bindStagedSupportAttachments(ctx, { + workspaceId: conversation.workspaceId, + attachmentIds: args.attachmentIds, + actor: { accessType: "visitor", visitorId: resolvedVisitorId }, + binding: { kind: "message", messageId }, + }); + } else if (args.senderType === "agent" && authenticatedUserId) { + attachedIds = await bindStagedSupportAttachments(ctx, { + workspaceId: conversation.workspaceId, + attachmentIds: args.attachmentIds, + actor: { accessType: "agent", userId: authenticatedUserId }, + binding: { kind: "message", messageId }, + }); + } + + if (attachedIds.length > 0) { + await ctx.db.patch(messageId, { + attachmentIds: attachedIds, + }); + } + const updateData: { updatedAt: number; lastMessageAt: number; @@ -192,10 +241,15 @@ export const send = mutation({ await ctx.db.patch(args.conversationId, updateData); + const messagePreview = + args.content.trim().length > 0 + ? args.content + : (describeSupportAttachmentSelection(attachedIds) ?? args.content); + const runAfter = getShallowRunAfter(ctx); await runAfter(0, notifyNewMessageRef, { conversationId: args.conversationId, - messageContent: args.content, + messageContent: messagePreview, senderType: args.senderType, messageId, senderId: args.senderId, diff --git a/packages/convex/convex/schema.ts b/packages/convex/convex/schema.ts index 2fcf29d..8304c8c 100644 --- a/packages/convex/convex/schema.ts +++ b/packages/convex/convex/schema.ts @@ -7,6 +7,7 @@ import { helpCenterTables } from "./schema/helpCenterTables"; import { inboxNotificationTables } from "./schema/inboxNotificationTables"; import { operationsTables } from "./schema/operationsTables"; import { outboundSupportTables } from "./schema/outboundSupportTables"; +import { supportAttachmentTables } from "./schema/supportAttachmentTables"; export default defineSchema({ ...authTables, @@ -15,6 +16,7 @@ export default defineSchema({ ...helpCenterTables, ...engagementTables, ...outboundSupportTables, + ...supportAttachmentTables, ...campaignTables, ...operationsTables, }); diff --git a/packages/convex/convex/schema/authWorkspaceTables.ts b/packages/convex/convex/schema/authWorkspaceTables.ts index 141cf9e..84a2dfb 100644 --- a/packages/convex/convex/schema/authWorkspaceTables.ts +++ b/packages/convex/convex/schema/authWorkspaceTables.ts @@ -55,6 +55,7 @@ export const authWorkspaceTables = { identityVerificationMode: v.optional(v.union(v.literal("optional"), v.literal("required"))), // Signed widget sessions (always on — sessionLifetimeMs configures per-workspace lifetime) sessionLifetimeMs: v.optional(v.number()), + supportAttachmentCleanupScheduledAt: v.optional(v.number()), }) .index("by_name", ["name"]) .index("by_created_at", ["createdAt"]), diff --git a/packages/convex/convex/schema/inboxConversationTables.ts b/packages/convex/convex/schema/inboxConversationTables.ts index 36bda35..26a6074 100644 --- a/packages/convex/convex/schema/inboxConversationTables.ts +++ b/packages/convex/convex/schema/inboxConversationTables.ts @@ -1,5 +1,6 @@ import { defineTable } from "convex/server"; import { v } from "convex/values"; +import { supportAttachmentIdArrayValidator } from "../supportAttachmentTypes"; import { customAttributesValidator } from "../validators"; export const inboxConversationTables = { @@ -141,6 +142,7 @@ export const inboxConversationTables = { v.literal("failed") ) ), + attachmentIds: v.optional(supportAttachmentIdArrayValidator), createdAt: v.number(), }) .index("by_conversation", ["conversationId"]) diff --git a/packages/convex/convex/schema/outboundSupportTables.ts b/packages/convex/convex/schema/outboundSupportTables.ts index f7a72a2..27d4a4d 100644 --- a/packages/convex/convex/schema/outboundSupportTables.ts +++ b/packages/convex/convex/schema/outboundSupportTables.ts @@ -1,5 +1,6 @@ import { defineTable } from "convex/server"; import { v } from "convex/values"; +import { supportAttachmentIdArrayValidator } from "../supportAttachmentTypes"; import { audienceRulesOrSegmentValidator, formDataValidator, jsonValueValidator } from "../validators"; import { outboundImpressionActionValidator, @@ -158,6 +159,7 @@ export const outboundSupportTables = { formId: v.optional(v.id("ticketForms")), formData: v.optional(formDataValidator), resolutionSummary: v.optional(v.string()), + attachmentIds: v.optional(supportAttachmentIdArrayValidator), createdAt: v.number(), updatedAt: v.number(), resolvedAt: v.optional(v.number()), @@ -175,6 +177,7 @@ export const outboundSupportTables = { authorType: v.union(v.literal("agent"), v.literal("visitor"), v.literal("system")), content: v.string(), isInternal: v.boolean(), + attachmentIds: v.optional(supportAttachmentIdArrayValidator), createdAt: v.number(), }).index("by_ticket", ["ticketId"]), diff --git a/packages/convex/convex/schema/supportAttachmentTables.ts b/packages/convex/convex/schema/supportAttachmentTables.ts new file mode 100644 index 0000000..079641e --- /dev/null +++ b/packages/convex/convex/schema/supportAttachmentTables.ts @@ -0,0 +1,31 @@ +import { defineTable } from "convex/server"; +import { v } from "convex/values"; +import { + supportAttachmentStatusValidator, + supportAttachmentUploaderTypeValidator, +} from "../supportAttachmentTypes"; + +export const supportAttachmentTables = { + supportAttachments: defineTable({ + workspaceId: v.id("workspaces"), + storageId: v.id("_storage"), + fileName: v.string(), + mimeType: v.string(), + size: v.number(), + status: supportAttachmentStatusValidator, + messageId: v.optional(v.id("messages")), + ticketId: v.optional(v.id("tickets")), + ticketCommentId: v.optional(v.id("ticketComments")), + uploadedByType: supportAttachmentUploaderTypeValidator, + uploadedById: v.string(), + createdAt: v.number(), + attachedAt: v.optional(v.number()), + expiresAt: v.optional(v.number()), + }) + .index("by_workspace", ["workspaceId"]) + .index("by_message", ["messageId"]) + .index("by_ticket", ["ticketId"]) + .index("by_ticket_comment", ["ticketCommentId"]) + .index("by_status_expires", ["status", "expiresAt"]) + .index("by_workspace_status_expires", ["workspaceId", "status", "expiresAt"]), +}; diff --git a/packages/convex/convex/supportAttachmentFunctionRefs.ts b/packages/convex/convex/supportAttachmentFunctionRefs.ts new file mode 100644 index 0000000..26a2abb --- /dev/null +++ b/packages/convex/convex/supportAttachmentFunctionRefs.ts @@ -0,0 +1,25 @@ +import { makeFunctionReference, type FunctionReference } from "convex/server"; +import type { Id } from "./_generated/dataModel"; + +type InternalMutationRef, Return = unknown> = + FunctionReference<"mutation", "internal", Args, Return>; + +export type CleanupExpiredStagedUploadsArgs = { + workspaceId: Id<"workspaces">; + scheduledAt: number; + limit?: number; +}; + +type CleanupExpiredStagedUploadsResult = { + deleted: number; + hasMore: boolean; +}; + +export const cleanupExpiredStagedUploadsRef = makeFunctionReference< + "mutation", + CleanupExpiredStagedUploadsArgs, + CleanupExpiredStagedUploadsResult +>("supportAttachments:cleanupExpiredStagedUploads") as unknown as InternalMutationRef< + CleanupExpiredStagedUploadsArgs, + CleanupExpiredStagedUploadsResult +>; diff --git a/packages/convex/convex/supportAttachmentTypes.ts b/packages/convex/convex/supportAttachmentTypes.ts new file mode 100644 index 0000000..6320cb0 --- /dev/null +++ b/packages/convex/convex/supportAttachmentTypes.ts @@ -0,0 +1,33 @@ +import { v } from "convex/values"; + +export const supportAttachmentStatusValidator = v.union( + v.literal("staged"), + v.literal("attached") +); + +export const supportAttachmentUploaderTypeValidator = v.union( + v.literal("agent"), + v.literal("visitor") +); + +export const supportAttachmentIdArrayValidator = v.array(v.id("supportAttachments")); + +export const MAX_SUPPORT_ATTACHMENT_BYTES = 10 * 1024 * 1024; +export const MAX_SUPPORT_ATTACHMENTS_PER_PARENT = 5; +export const STAGED_SUPPORT_ATTACHMENT_TTL_MS = 60 * 60 * 1000; + +export const SUPPORTED_SUPPORT_ATTACHMENT_MIME_TYPES = new Set([ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + "application/json", + "application/pdf", + "application/x-zip-compressed", + "application/zip", + "text/csv", + "text/plain", +]); + +export const SUPPORTED_SUPPORT_ATTACHMENT_TYPE_LABEL = + "PNG, JPEG, GIF, WEBP, PDF, TXT, CSV, JSON, and ZIP"; diff --git a/packages/convex/convex/supportAttachments.ts b/packages/convex/convex/supportAttachments.ts new file mode 100644 index 0000000..6a486de --- /dev/null +++ b/packages/convex/convex/supportAttachments.ts @@ -0,0 +1,530 @@ +import { v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; +import { + internalMutation, + mutation, + type MutationCtx, + type QueryCtx, +} from "./_generated/server"; +import { getAuthenticatedUserFromSession } from "./auth"; +import { requirePermission } from "./permissions"; +import { + MAX_SUPPORT_ATTACHMENT_BYTES, + MAX_SUPPORT_ATTACHMENTS_PER_PARENT, + STAGED_SUPPORT_ATTACHMENT_TTL_MS, + SUPPORTED_SUPPORT_ATTACHMENT_TYPE_LABEL, +} from "./supportAttachmentTypes"; +import { createError } from "./utils/errors"; +import { resolveVisitorFromSession } from "./widgetSessions"; +import { getShallowRunAfter } from "./notifications/functionRefs"; +import { + cleanupExpiredStagedUploadsRef, + type CleanupExpiredStagedUploadsArgs, +} from "./supportAttachmentFunctionRefs"; + +type SupportAttachmentWorkspaceArgs = { + workspaceId: Id<"workspaces">; + visitorId?: Id<"visitors">; + sessionToken?: string; +}; + +type SupportAttachmentActor = + | { accessType: "agent"; userId: Id<"users"> } + | { accessType: "visitor"; visitorId: Id<"visitors"> }; + +export type SupportAttachmentDescriptor = { + _id: Id<"supportAttachments">; + fileName: string; + mimeType: string; + size: number; + url?: string; +}; + +const DEFAULT_CLEANUP_LIMIT = 100; +const MAX_CLEANUP_LIMIT = 500; +const CLEANUP_SCHEDULE_GRACE_MS = 1_000; +const CLEANUP_EXPIRED_STAGED_UPLOADS_REF = cleanupExpiredStagedUploadsRef; +const SUPPORT_ATTACHMENT_MIME_TYPE_BY_EXTENSION = { + csv: "text/csv", + json: "application/json", + pdf: "application/pdf", + gif: "image/gif", + jpeg: "image/jpeg", + jpg: "image/jpeg", + png: "image/png", + txt: "text/plain", + webp: "image/webp", + zip: "application/zip", +} as const; + +function normalizeAttachmentIds( + attachmentIds?: readonly Id<"supportAttachments">[] +): Id<"supportAttachments">[] { + if (!attachmentIds || attachmentIds.length === 0) { + return []; + } + + return [...new Set(attachmentIds)]; +} + +function normalizeAttachmentFileName(fileName?: string): string { + const normalized = fileName?.split(/[/\\]/).at(-1)?.trim(); + return normalized && normalized.length > 0 ? normalized : "attachment"; +} + +function getSupportAttachmentExtension( + fileName: string +): keyof typeof SUPPORT_ATTACHMENT_MIME_TYPE_BY_EXTENSION | null { + const lastDotIndex = fileName.lastIndexOf("."); + if (lastDotIndex <= 0 || lastDotIndex === fileName.length - 1) { + return null; + } + + const extension = fileName.slice(lastDotIndex + 1).toLowerCase(); + return extension in SUPPORT_ATTACHMENT_MIME_TYPE_BY_EXTENSION + ? (extension as keyof typeof SUPPORT_ATTACHMENT_MIME_TYPE_BY_EXTENSION) + : null; +} + +function getSupportAttachmentMimeTypeForFileName(fileName: string): string | null { + const extension = getSupportAttachmentExtension(fileName); + return extension ? SUPPORT_ATTACHMENT_MIME_TYPE_BY_EXTENSION[extension] : null; +} +function validateSupportAttachmentFileName( + normalizedFileName: string +): + | { + status: "accepted"; + mimeType: string; + } + | { + status: "rejected"; + message: string; + } +{ + const extension = getSupportAttachmentExtension(normalizedFileName); + if (!extension) { + return { + status: "rejected", + message: `Unsupported file type. Allowed: ${SUPPORTED_SUPPORT_ATTACHMENT_TYPE_LABEL}.`, + }; + } + + return { + status: "accepted", + mimeType: getSupportAttachmentMimeTypeForFileName(normalizedFileName)!, + }; +} + +function getActorUploadId(actor: SupportAttachmentActor): string { + return actor.accessType === "agent" ? actor.userId : actor.visitorId; +} + +async function ensureWorkspaceExists( + ctx: Pick, + workspaceId: Id<"workspaces"> +): Promise { + const workspace = await ctx.db.get(workspaceId); + if (!workspace) { + throw createError("NOT_FOUND", "Workspace not found"); + } +} + +async function requireSupportAttachmentWorkspaceAccess( + ctx: QueryCtx | MutationCtx, + args: SupportAttachmentWorkspaceArgs +): Promise { + const authUser = await getAuthenticatedUserFromSession(ctx); + if (authUser) { + await requirePermission(ctx, authUser._id, args.workspaceId, "conversations.reply"); + return { + accessType: "agent", + userId: authUser._id, + }; + } + + if (!args.sessionToken) { + throw createError("NOT_AUTHENTICATED"); + } + + const resolved = await resolveVisitorFromSession(ctx, { + sessionToken: args.sessionToken, + workspaceId: args.workspaceId, + }); + if (args.visitorId && args.visitorId !== resolved.visitorId) { + throw createError("NOT_AUTHORIZED", "Not authorized to upload files for this visitor"); + } + + return { + accessType: "visitor", + visitorId: resolved.visitorId, + }; +} + +async function deleteUploadedFileIfPresent( + ctx: Pick, + storageId: Id<"_storage"> +): Promise { + const metadata = await ctx.storage.getMetadata(storageId); + if (!metadata) { + return; + } + await ctx.storage.delete(storageId); +} + +function getSupportAttachmentCleanupScheduledAt(expiresAt: number): number { + return expiresAt + CLEANUP_SCHEDULE_GRACE_MS; +} + +async function getNextStagedSupportAttachment( + ctx: Pick, + workspaceId: Id<"workspaces"> +): Promise | null> { + return await ctx.db + .query("supportAttachments") + .withIndex("by_workspace_status_expires", (q) => + q.eq("workspaceId", workspaceId).eq("status", "staged") + ) + .first(); +} + +async function scheduleSupportAttachmentCleanup( + ctx: Pick, + args: CleanupExpiredStagedUploadsArgs +): Promise { + const runAfter = getShallowRunAfter(ctx); + await runAfter( + Math.max(0, args.scheduledAt - Date.now()), + CLEANUP_EXPIRED_STAGED_UPLOADS_REF, + args + ); +} + +async function ensureSupportAttachmentCleanupScheduled( + ctx: Pick, + workspaceId: Id<"workspaces">, + scheduledAt: number +): Promise { + const workspace = await ctx.db.get(workspaceId); + if (!workspace) { + throw createError("NOT_FOUND", "Workspace not found"); + } + + const existingScheduledAt = workspace.supportAttachmentCleanupScheduledAt; + if (typeof existingScheduledAt === "number" && existingScheduledAt <= scheduledAt) { + return; + } + + await ctx.db.patch(workspaceId, { + supportAttachmentCleanupScheduledAt: scheduledAt, + }); + + await scheduleSupportAttachmentCleanup(ctx, { + workspaceId, + scheduledAt, + limit: DEFAULT_CLEANUP_LIMIT, + }); +} + +function buildAttachmentCountError(): Error { + return createError( + "INVALID_INPUT", + `You can attach up to ${MAX_SUPPORT_ATTACHMENTS_PER_PARENT} files at a time.` + ); +} + +function buildAttachmentPreviewLabel(attachmentCount: number): string { + return attachmentCount === 1 ? "1 attachment" : `${attachmentCount} attachments`; +} + +type SupportAttachmentBinding = + | { + kind: "message"; + messageId: Id<"messages">; + } + | { + kind: "ticket"; + ticketId: Id<"tickets">; + } + | { + kind: "ticketComment"; + ticketCommentId: Id<"ticketComments">; + }; + +type BindSupportAttachmentsArgs = { + workspaceId: Id<"workspaces">; + attachmentIds?: readonly Id<"supportAttachments">[]; + actor: SupportAttachmentActor; + binding: SupportAttachmentBinding; +}; + +export function describeSupportAttachmentSelection( + attachmentIds?: readonly Id<"supportAttachments">[] +): string | null { + const normalizedIds = normalizeAttachmentIds(attachmentIds); + if (normalizedIds.length === 0) { + return null; + } + return buildAttachmentPreviewLabel(normalizedIds.length); +} + +export async function bindStagedSupportAttachments( + ctx: MutationCtx, + args: BindSupportAttachmentsArgs +): Promise[]> { + const attachmentIds = normalizeAttachmentIds(args.attachmentIds); + if (attachmentIds.length === 0) { + return []; + } + if (attachmentIds.length > MAX_SUPPORT_ATTACHMENTS_PER_PARENT) { + throw buildAttachmentCountError(); + } + + const now = Date.now(); + const attachments = await Promise.all( + attachmentIds.map( + async (attachmentId) => + [attachmentId, (await ctx.db.get(attachmentId)) as Doc<"supportAttachments"> | null] as const + ) + ); + + for (const [attachmentId, attachment] of attachments) { + if (!attachment || attachment.workspaceId !== args.workspaceId) { + throw createError("NOT_FOUND", `Attachment ${attachmentId} was not found`); + } + if (attachment.status !== "staged") { + throw createError("INVALID_INPUT", "Attachment has already been used"); + } + if (!attachment.expiresAt || attachment.expiresAt <= now) { + throw createError("INVALID_INPUT", "Attachment upload expired. Please upload again."); + } + if ( + attachment.messageId || + attachment.ticketId || + attachment.ticketCommentId + ) { + throw createError("INVALID_INPUT", "Attachment has already been attached"); + } + if (attachment.uploadedByType !== args.actor.accessType) { + throw createError("NOT_AUTHORIZED", "Not authorized to use this attachment"); + } + if (attachment.uploadedById !== getActorUploadId(args.actor)) { + throw createError("NOT_AUTHORIZED", "Not authorized to use this attachment"); + } + } + + for (const [, attachment] of attachments) { + if (!attachment) { + continue; + } + await ctx.db.patch(attachment._id, { + status: "attached", + messageId: args.binding.kind === "message" ? args.binding.messageId : undefined, + ticketId: args.binding.kind === "ticket" ? args.binding.ticketId : undefined, + ticketCommentId: + args.binding.kind === "ticketComment" ? args.binding.ticketCommentId : undefined, + attachedAt: now, + expiresAt: undefined, + }); + } + + return attachmentIds; +} + +type AttachmentReadCtx = Pick; + +export async function loadSupportAttachmentDescriptorMap( + ctx: AttachmentReadCtx, + attachmentIds: readonly Id<"supportAttachments">[] +): Promise> { + const normalizedIds = normalizeAttachmentIds(attachmentIds); + if (normalizedIds.length === 0) { + return new Map(); + } + + const rawEntries = await Promise.all( + normalizedIds.map(async (attachmentId) => { + const attachment = (await ctx.db.get(attachmentId)) as Doc<"supportAttachments"> | null; + if (!attachment || attachment.status !== "attached") { + return null; + } + return [ + attachmentId.toString(), + { + _id: attachment._id, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + size: attachment.size, + url: (await ctx.storage.getUrl(attachment.storageId)) ?? undefined, + } satisfies SupportAttachmentDescriptor, + ] as const; + }) + ); + + const entries: Array = []; + for (const entry of rawEntries) { + if (entry) { + entries.push(entry); + } + } + + return new Map(entries); +} + +export function materializeSupportAttachmentDescriptors( + attachmentIds: readonly Id<"supportAttachments">[] | undefined, + descriptorMap: Map +): SupportAttachmentDescriptor[] { + const normalizedIds = normalizeAttachmentIds(attachmentIds); + return normalizedIds.flatMap((attachmentId) => { + const descriptor = descriptorMap.get(attachmentId.toString()); + return descriptor ? [descriptor] : []; + }); +} + +export const generateUploadUrl = mutation({ + args: { + workspaceId: v.id("workspaces"), + visitorId: v.optional(v.id("visitors")), + sessionToken: v.optional(v.string()), + }, + handler: async (ctx, args) => { + await ensureWorkspaceExists(ctx, args.workspaceId); + await requireSupportAttachmentWorkspaceAccess(ctx, args); + return await ctx.storage.generateUploadUrl(); + }, +}); + +export const finalizeUpload = mutation({ + args: { + workspaceId: v.id("workspaces"), + visitorId: v.optional(v.id("visitors")), + sessionToken: v.optional(v.string()), + storageId: v.id("_storage"), + fileName: v.optional(v.string()), + }, + handler: async (ctx, args) => { + await ensureWorkspaceExists(ctx, args.workspaceId); + const actor = await requireSupportAttachmentWorkspaceAccess(ctx, args); + const normalizedFileName = normalizeAttachmentFileName(args.fileName); + + const metadata = await ctx.storage.getMetadata(args.storageId); + if (!metadata) { + throw createError("NOT_FOUND", "Uploaded file not found"); + } + + const fileValidation = validateSupportAttachmentFileName(normalizedFileName); + if (fileValidation.status === "rejected") { + await deleteUploadedFileIfPresent(ctx, args.storageId); + return { + status: "rejected" as const, + message: fileValidation.message, + }; + } + + if (metadata.size > MAX_SUPPORT_ATTACHMENT_BYTES) { + await deleteUploadedFileIfPresent(ctx, args.storageId); + return { + status: "rejected" as const, + message: `File exceeds ${Math.floor(MAX_SUPPORT_ATTACHMENT_BYTES / (1024 * 1024))}MB maximum size.`, + }; + } + + const now = Date.now(); + const attachmentId = await ctx.db.insert("supportAttachments", { + workspaceId: args.workspaceId, + storageId: args.storageId, + fileName: normalizedFileName, + mimeType: fileValidation.mimeType, + size: metadata.size, + status: "staged", + uploadedByType: actor.accessType, + uploadedById: getActorUploadId(actor), + createdAt: now, + expiresAt: now + STAGED_SUPPORT_ATTACHMENT_TTL_MS, + }); + + await ensureSupportAttachmentCleanupScheduled( + ctx, + args.workspaceId, + getSupportAttachmentCleanupScheduledAt(now + STAGED_SUPPORT_ATTACHMENT_TTL_MS) + ); + + return { + status: "staged" as const, + attachmentId, + fileName: normalizedFileName, + mimeType: fileValidation.mimeType, + size: metadata.size, + }; + }, +}); + +export const cleanupExpiredStagedUploads = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + scheduledAt: v.number(), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const workspace = await ctx.db.get(args.workspaceId); + if (!workspace) { + return { + deleted: 0, + hasMore: false, + }; + } + if (workspace.supportAttachmentCleanupScheduledAt !== args.scheduledAt) { + return { + deleted: 0, + hasMore: false, + }; + } + + const now = Date.now(); + const limit = Math.max( + 1, + Math.min(args.limit ?? DEFAULT_CLEANUP_LIMIT, MAX_CLEANUP_LIMIT) + ); + + const expiredUploads = await ctx.db + .query("supportAttachments") + .withIndex("by_workspace_status_expires", (q) => + q.eq("workspaceId", args.workspaceId).eq("status", "staged").lt("expiresAt", now) + ) + .take(limit); + + let deleted = 0; + for (const attachment of expiredUploads) { + await deleteUploadedFileIfPresent(ctx, attachment.storageId); + await ctx.db.delete(attachment._id); + deleted += 1; + } + + const nextStagedAttachment = await getNextStagedSupportAttachment(ctx, args.workspaceId); + if (!nextStagedAttachment?.expiresAt) { + await ctx.db.patch(args.workspaceId, { + supportAttachmentCleanupScheduledAt: undefined, + }); + return { + deleted, + hasMore: expiredUploads.length === limit, + }; + } + + const nextScheduledAt = getSupportAttachmentCleanupScheduledAt(nextStagedAttachment.expiresAt); + await ctx.db.patch(args.workspaceId, { + supportAttachmentCleanupScheduledAt: nextScheduledAt, + }); + await scheduleSupportAttachmentCleanup(ctx, { + workspaceId: args.workspaceId, + scheduledAt: nextScheduledAt, + limit: args.limit ?? DEFAULT_CLEANUP_LIMIT, + }); + + return { + deleted, + hasMore: expiredUploads.length === limit, + }; + }, +}); diff --git a/packages/convex/convex/testData/cleanup.ts b/packages/convex/convex/testData/cleanup.ts index 399231d..6fcf986 100644 --- a/packages/convex/convex/testData/cleanup.ts +++ b/packages/convex/convex/testData/cleanup.ts @@ -1,5 +1,6 @@ -import { internalMutation } from "../_generated/server"; +import { internalMutation, type MutationCtx } from "../_generated/server"; import { v } from "convex/values"; +import { type Id } from "../_generated/dataModel"; import { formatReadableVisitorId } from "../visitorReadableId"; const E2E_TEST_PREFIX = "e2e_test_"; @@ -10,6 +11,35 @@ function requireTestDataEnabled() { } } +async function deleteSupportAttachmentById( + ctx: Pick, + attachmentId: Id<"supportAttachments"> +): Promise { + const attachment = await ctx.db.get(attachmentId); + if (!attachment) { + return; + } + + const metadata = await ctx.storage.getMetadata(attachment.storageId); + if (metadata) { + await ctx.storage.delete(attachment.storageId); + } + await ctx.db.delete(attachment._id); +} + +async function deleteSupportAttachmentIds( + ctx: Pick, + attachmentIds: readonly Id<"supportAttachments">[] | undefined +): Promise { + if (!attachmentIds || attachmentIds.length === 0) { + return; + } + + for (const attachmentId of [...new Set(attachmentIds)]) { + await deleteSupportAttachmentById(ctx, attachmentId); + } +} + const cleanupTestData = internalMutation({ args: { workspaceId: v.id("workspaces"), @@ -260,11 +290,13 @@ const cleanupTestData = internalMutation({ for (const ticket of tickets) { if (ticket.subject.startsWith(E2E_TEST_PREFIX)) { + await deleteSupportAttachmentIds(ctx, ticket.attachmentIds); const comments = await ctx.db .query("ticketComments") .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id)) .collect(); for (const comment of comments) { + await deleteSupportAttachmentIds(ctx, comment.attachmentIds); await ctx.db.delete(comment._id); } await ctx.db.delete(ticket._id); @@ -303,6 +335,7 @@ const cleanupTestData = internalMutation({ .withIndex("by_conversation", (q) => q.eq("conversationId", conversation._id)) .collect(); for (const message of messages) { + await deleteSupportAttachmentIds(ctx, message.attachmentIds); await ctx.db.delete(message._id); } await ctx.db.delete(conversation._id); diff --git a/packages/convex/convex/testing/helpers.ts b/packages/convex/convex/testing/helpers.ts index 4467231..060308e 100644 --- a/packages/convex/convex/testing/helpers.ts +++ b/packages/convex/convex/testing/helpers.ts @@ -8,6 +8,7 @@ import { emailTestHelpers } from "./helpers/email"; import { ticketTestHelpers } from "./helpers/tickets"; import { aiTestHelpers } from "./helpers/ai"; import { cleanupTestHelpers } from "./helpers/cleanup"; +import { supportAttachmentTestHelpers } from "./helpers/supportAttachments"; export const createTestWorkspace: ReturnType = workspaceTestHelpers.createTestWorkspace; export const updateTestHelpCenterAccessPolicy: ReturnType = workspaceTestHelpers.updateTestHelpCenterAccessPolicy; @@ -92,3 +93,7 @@ export const updateWorkspaceOrigins: ReturnType = works export const createTestConversationForVisitor: ReturnType = conversationTestHelpers.createTestConversationForVisitor; export const lookupUserByEmail: ReturnType = workspaceTestHelpers.lookupUserByEmail; export const lookupPendingInvitationsByEmail: ReturnType = workspaceTestHelpers.lookupPendingInvitationsByEmail; +export const cleanupExpiredSupportAttachments: ReturnType = supportAttachmentTestHelpers.cleanupExpiredSupportAttachments; +export const expireTestSupportAttachment: ReturnType = supportAttachmentTestHelpers.expireTestSupportAttachment; +export const getTestSupportAttachment: ReturnType = supportAttachmentTestHelpers.getTestSupportAttachment; +export const hasTestStoredFile: ReturnType = supportAttachmentTestHelpers.hasTestStoredFile; diff --git a/packages/convex/convex/testing/helpers/cleanup.ts b/packages/convex/convex/testing/helpers/cleanup.ts index 7b44b3f..8a99758 100644 --- a/packages/convex/convex/testing/helpers/cleanup.ts +++ b/packages/convex/convex/testing/helpers/cleanup.ts @@ -1,7 +1,25 @@ -import { internalMutation } from "../../_generated/server"; +import { internalMutation, type MutationCtx } from "../../_generated/server"; import { v } from "convex/values"; import { Id } from "../../_generated/dataModel"; +async function deleteSupportAttachmentsForWorkspace( + ctx: Pick, + workspaceId: Id<"workspaces"> +): Promise { + const attachments = await ctx.db + .query("supportAttachments") + .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)) + .collect(); + + for (const attachment of attachments) { + const metadata = await ctx.storage.getMetadata(attachment.storageId); + if (metadata) { + await ctx.storage.delete(attachment.storageId); + } + await ctx.db.delete(attachment._id); + } +} + const cleanupTestData = internalMutation({ args: { workspaceId: v.id("workspaces"), @@ -265,6 +283,8 @@ const cleanupTestData = internalMutation({ await ctx.db.delete(form._id); } + await deleteSupportAttachmentsForWorkspace(ctx, workspaceId); + await ctx.db.delete(workspaceId); return { success: true }; @@ -371,6 +391,8 @@ const cleanupE2ETestData = internalMutation({ await ctx.db.delete(defaults._id); } + await deleteSupportAttachmentsForWorkspace(ctx, workspaceId); + // Delete the workspace try { await ctx.db.delete(workspaceId); diff --git a/packages/convex/convex/testing/helpers/supportAttachments.ts b/packages/convex/convex/testing/helpers/supportAttachments.ts new file mode 100644 index 0000000..9e08039 --- /dev/null +++ b/packages/convex/convex/testing/helpers/supportAttachments.ts @@ -0,0 +1,74 @@ +import { internalMutation, type MutationCtx } from "../../_generated/server"; +import { v } from "convex/values"; +import { type Id } from "../../_generated/dataModel"; + +async function deleteStoredFileIfPresent( + ctx: Pick, + storageId: Id<"_storage"> +): Promise { + const metadata = await ctx.storage.getMetadata(storageId); + if (metadata) { + await ctx.storage.delete(storageId); + } +} + +const getTestSupportAttachment = internalMutation({ + args: { + attachmentId: v.id("supportAttachments"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.attachmentId); + }, +}); + +const expireTestSupportAttachment = internalMutation({ + args: { + attachmentId: v.id("supportAttachments"), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.attachmentId, { + status: "staged", + expiresAt: Date.now() - 1_000, + }); + }, +}); + +const cleanupExpiredSupportAttachments = internalMutation({ + args: { + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const limit = Math.max(1, Math.min(args.limit ?? 100, 500)); + const attachments = await ctx.db + .query("supportAttachments") + .withIndex("by_status_expires", (q) => q.eq("status", "staged").lt("expiresAt", now)) + .take(limit); + + for (const attachment of attachments) { + await deleteStoredFileIfPresent(ctx, attachment.storageId); + await ctx.db.delete(attachment._id); + } + + return { + deleted: attachments.length, + hasMore: attachments.length === limit, + }; + }, +}); + +const hasTestStoredFile = internalMutation({ + args: { + storageId: v.id("_storage"), + }, + handler: async (ctx, args) => { + return Boolean(await ctx.storage.getMetadata(args.storageId)); + }, +}); + +export const supportAttachmentTestHelpers: Record> = { + cleanupExpiredSupportAttachments, + expireTestSupportAttachment, + getTestSupportAttachment, + hasTestStoredFile, +} as const; diff --git a/packages/convex/convex/tickets.ts b/packages/convex/convex/tickets.ts index 05b877e..ae74ba4 100644 --- a/packages/convex/convex/tickets.ts +++ b/packages/convex/convex/tickets.ts @@ -5,6 +5,13 @@ import { Doc, Id } from "./_generated/dataModel"; import { resolveVisitorFromSession } from "./widgetSessions"; import { getAuthenticatedUserFromSession } from "./auth"; import { hasPermission, requirePermission } from "./permissions"; +import { + bindStagedSupportAttachments, + loadSupportAttachmentDescriptorMap, + materializeSupportAttachmentDescriptors, + type SupportAttachmentDescriptor, +} from "./supportAttachments"; +import { supportAttachmentIdArrayValidator } from "./supportAttachmentTypes"; import { formDataValidator } from "./validators"; import { authMutation, authQuery } from "./lib/authWrappers"; @@ -208,6 +215,31 @@ async function getTicketDirectoryAccessStatus( return { status: "ok", userId: user._id }; } +type AttachmentCarrier = { + attachmentIds?: readonly Id<"supportAttachments">[]; +}; + +async function withSupportAttachments( + ctx: Pick, + records: readonly T[] +): Promise> { + const descriptorMap = await loadSupportAttachmentDescriptorMap( + ctx, + records.flatMap((record) => record.attachmentIds ?? []) + ); + + return records.map((record) => ({ + ...record, + attachments: materializeSupportAttachmentDescriptors(record.attachmentIds, descriptorMap), + })); +} + +function getSupportAttachmentActor(access: WorkspaceTicketAccessResult) { + return access.accessType === "agent" + ? { accessType: "agent" as const, userId: access.userId } + : { accessType: "visitor" as const, visitorId: access.visitorId }; +} + async function listTicketsWithEnrichment( ctx: QueryCtx, args: { @@ -274,8 +306,9 @@ async function listTicketsWithEnrichment( const visitorsById = new Map(visitorEntries); const assigneesById = new Map(assigneeEntries); + const ticketsWithAttachments = await withSupportAttachments(ctx, tickets); - return tickets.map((ticket) => ({ + return ticketsWithAttachments.map((ticket) => ({ ...ticket, visitor: ticket.visitorId ? (visitorsById.get(ticket.visitorId) ?? null) : null, assignee: ticket.assigneeId ? (assigneesById.get(ticket.assigneeId) ?? null) : null, @@ -314,6 +347,7 @@ export const create = mutation({ sessionToken: v.optional(v.string()), subject: v.string(), description: v.optional(v.string()), + attachmentIds: v.optional(supportAttachmentIdArrayValidator), priority: v.optional(ticketPriorityValidator), formId: v.optional(v.id("ticketForms")), formData: v.optional(formDataValidator), @@ -349,6 +383,19 @@ export const create = mutation({ updatedAt: now, }); + const attachedIds = await bindStagedSupportAttachments(ctx, { + workspaceId: args.workspaceId, + attachmentIds: args.attachmentIds, + actor: getSupportAttachmentActor(access), + binding: { kind: "ticket", ticketId }, + }); + + if (attachedIds.length > 0) { + await ctx.db.patch(ticketId, { + attachmentIds: attachedIds, + }); + } + await scheduleTicketCreatedNotification(ctx, { ticketId, }); @@ -486,18 +533,21 @@ export const get = query({ conversation = (await ctx.db.get(ticket.conversationId)) as Doc<"conversations"> | null; } - const comments = await ctx.db + const comments = (await ctx.db .query("ticketComments") .withIndex("by_ticket", (q) => q.eq("ticketId", args.id)) .order("asc") - .collect(); + .collect()) as Doc<"ticketComments">[]; + + const [ticketWithAttachments] = await withSupportAttachments(ctx, [ticket]); + const commentsWithAttachments = await withSupportAttachments(ctx, comments); return { - ...ticket, + ...ticketWithAttachments, visitor, assignee, conversation, - comments, + comments: commentsWithAttachments, }; }, }); @@ -543,15 +593,17 @@ export const getForAdminView = query({ .withIndex("by_ticket", (q) => q.eq("ticketId", args.id)) .order("asc") .collect()) as Doc<"ticketComments">[]; + const [ticketWithAttachments] = await withSupportAttachments(ctx, [ticket]); + const commentsWithAttachments = await withSupportAttachments(ctx, comments); return { status: "ok" as const, ticket: { - ...ticket, + ...ticketWithAttachments, visitor, assignee, conversation, - comments, + comments: commentsWithAttachments, }, }; }, @@ -610,6 +662,7 @@ export const addComment = mutation({ ticketId: v.id("tickets"), visitorId: v.optional(v.id("visitors")), content: v.string(), + attachmentIds: v.optional(supportAttachmentIdArrayValidator), isInternal: v.optional(v.boolean()), authorId: v.optional(v.string()), authorType: v.optional(v.union(v.literal("agent"), v.literal("visitor"), v.literal("system"))), @@ -655,6 +708,19 @@ export const addComment = mutation({ createdAt: now, }); + const attachedIds = await bindStagedSupportAttachments(ctx, { + workspaceId: ticket.workspaceId, + attachmentIds: args.attachmentIds, + actor: getSupportAttachmentActor(access), + binding: { kind: "ticketComment", ticketCommentId: commentId }, + }); + + if (attachedIds.length > 0) { + await ctx.db.patch(commentId, { + attachmentIds: attachedIds, + }); + } + await ctx.db.patch(args.ticketId, { updatedAt: now }); if (!isInternal && authorType === "agent") { @@ -753,7 +819,7 @@ export const listByVisitor = query({ .order("desc") .collect()) as Doc<"tickets">[]; - return tickets; + return await withSupportAttachments(ctx, tickets); }, }); @@ -780,10 +846,11 @@ export const getComments = query({ .order("asc") .collect()) as Doc<"ticketComments">[]; - if (args.includeInternal && access.accessType === "agent") { - return comments; - } + const visibleComments = + args.includeInternal && access.accessType === "agent" + ? comments + : comments.filter((comment) => !comment.isInternal); - return comments.filter((c) => !c.isInternal); + return await withSupportAttachments(ctx, visibleComments); }, }); diff --git a/packages/convex/tests/runtimeTypeHardeningGuard.test.ts b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts index 91d3a56..a059707 100644 --- a/packages/convex/tests/runtimeTypeHardeningGuard.test.ts +++ b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts @@ -16,6 +16,7 @@ const TARGET_FILES = [ "../convex/lib/authWrappers.ts", "../convex/internalArticles.ts", "../convex/snippets.ts", + "../convex/supportAttachments.ts", "../convex/testing/helpers/notifications.ts", "../convex/tickets.ts", "../convex/suggestions.ts", @@ -135,6 +136,20 @@ describe("runtime type hardening guards", () => { expect(suggestionsSource).toContain("VALIDATE_SESSION_TOKEN_REF"); }); + it("uses fixed typed refs for support attachment cleanup scheduling", () => { + const supportAttachmentsSource = readFileSync( + new URL("../convex/supportAttachments.ts", import.meta.url), + "utf8" + ); + + expect(supportAttachmentsSource).not.toContain("function getInternalRef(name: string)"); + expect(supportAttachmentsSource).not.toContain("makeFunctionReference("); + expect(supportAttachmentsSource).not.toContain("as unknown as"); + expect(supportAttachmentsSource).toContain("CLEANUP_EXPIRED_STAGED_UPLOADS_REF"); + expect(supportAttachmentsSource).toContain('from "./supportAttachmentFunctionRefs"'); + expect(supportAttachmentsSource).toContain('from "./notifications/functionRefs"'); + }); + it("uses fixed typed refs for HTTP origin validation", () => { const httpSource = readFileSync(new URL("../convex/http.ts", import.meta.url), "utf8"); diff --git a/packages/convex/tests/supportAttachments.test.ts b/packages/convex/tests/supportAttachments.test.ts new file mode 100644 index 0000000..6a5798e --- /dev/null +++ b/packages/convex/tests/supportAttachments.test.ts @@ -0,0 +1,333 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { ConvexClient } from "convex/browser"; +import { api } from "../convex/_generated/api"; +import type { Id } from "../convex/_generated/dataModel"; +import { authenticateClientForWorkspace } from "./helpers/authSession"; + +const ONE_BY_ONE_PNG_BYTES = Uint8Array.from([ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, + 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 15, 4, 0, 9, 251, 3, + 253, 160, 133, 37, 209, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, +]); + +const TEXT_FILE_BYTES = new TextEncoder().encode("support diagnostics"); + +type VisitorUploadContext = { + workspaceId: Id<"workspaces">; + visitorId: Id<"visitors">; + sessionToken: string; +}; + +type AgentUploadContext = { + workspaceId: Id<"workspaces">; +}; + +async function uploadSupportAttachment( + client: ConvexClient, + context: VisitorUploadContext | AgentUploadContext, + fileName: string, + mimeType: string, + bytes: Uint8Array +): Promise<{ + attachmentId: Id<"supportAttachments">; + storageId: Id<"_storage">; + fileName: string; + mimeType: string; + size: number; +}> { + const uploadUrl = await client.mutation(api.supportAttachments.generateUploadUrl, context); + const response = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": mimeType }, + body: bytes, + }); + expect(response.ok).toBe(true); + + const payload = (await response.json()) as { storageId?: Id<"_storage"> }; + if (!payload.storageId) { + throw new Error("Storage upload response did not include storageId"); + } + + const finalized = await client.mutation(api.supportAttachments.finalizeUpload, { + ...context, + storageId: payload.storageId, + fileName, + }); + + return { + ...finalized, + storageId: payload.storageId, + }; +} + +describe("support attachments", () => { + let agentClient: ConvexClient; + let visitorClient: ConvexClient; + let otherVisitorClient: ConvexClient; + let workspaceId: Id<"workspaces">; + let userId: Id<"users">; + let visitorId: Id<"visitors">; + let visitorSessionToken: string; + let visitorConversationId: Id<"conversations">; + let otherVisitorId: Id<"visitors">; + let otherVisitorSessionToken: string; + let otherConversationId: Id<"conversations">; + + beforeAll(async () => { + const convexUrl = process.env.CONVEX_URL; + if (!convexUrl) { + throw new Error("CONVEX_URL environment variable is required"); + } + + agentClient = new ConvexClient(convexUrl); + visitorClient = new ConvexClient(convexUrl); + otherVisitorClient = new ConvexClient(convexUrl); + + const authContext = await authenticateClientForWorkspace(agentClient); + workspaceId = authContext.workspaceId; + userId = authContext.userId; + + const visitor = await agentClient.mutation(api.testing_helpers.createTestVisitor, { + workspaceId, + email: "support-visitor@test.com", + name: "Support Visitor", + }); + visitorId = visitor.visitorId; + const visitorSession = await agentClient.mutation(api.testing_helpers.createTestSessionToken, { + visitorId, + workspaceId, + }); + visitorSessionToken = visitorSession.sessionToken; + const visitorConversation = await agentClient.mutation( + api.testing_helpers.createTestConversation, + { + workspaceId, + visitorId, + } + ); + visitorConversationId = visitorConversation.conversationId; + + const otherVisitor = await agentClient.mutation(api.testing_helpers.createTestVisitor, { + workspaceId, + email: "other-support-visitor@test.com", + name: "Other Visitor", + }); + otherVisitorId = otherVisitor.visitorId; + const otherVisitorSession = await agentClient.mutation( + api.testing_helpers.createTestSessionToken, + { + visitorId: otherVisitorId, + workspaceId, + } + ); + otherVisitorSessionToken = otherVisitorSession.sessionToken; + const otherConversation = await agentClient.mutation( + api.testing_helpers.createTestConversation, + { + workspaceId, + visitorId: otherVisitorId, + } + ); + otherConversationId = otherConversation.conversationId; + }); + + afterAll(async () => { + if (workspaceId && agentClient) { + try { + await agentClient.mutation(api.testing_helpers.cleanupTestData, { + workspaceId, + }); + } catch (error) { + console.warn("Cleanup failed:", error); + } + } + await Promise.allSettled([ + agentClient?.close?.() ?? Promise.resolve(), + visitorClient?.close?.() ?? Promise.resolve(), + otherVisitorClient?.close?.() ?? Promise.resolve(), + ]); + }); + + it("rejects uploads whose extension is not allowlisted and removes the uploaded storage object", async () => { + const uploadUrl = await agentClient.mutation(api.supportAttachments.generateUploadUrl, { + workspaceId, + }); + const response = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": "image/png" }, + body: TEXT_FILE_BYTES, + }); + expect(response.ok).toBe(true); + const payload = (await response.json()) as { storageId?: Id<"_storage"> }; + expect(payload.storageId).toBeDefined(); + + const result = await agentClient.mutation(api.supportAttachments.finalizeUpload, { + workspaceId, + storageId: payload.storageId!, + fileName: "unsupported.bin", + }); + expect(result).toMatchObject({ + status: "rejected", + }); + if (result.status !== "rejected") { + throw new Error("Expected rejected upload result"); + } + expect(result.message).toMatch(/Unsupported file type/); + + const stillExists = await agentClient.mutation(api.testing_helpers.hasTestStoredFile, { + storageId: payload.storageId!, + }); + expect(stillExists).toBe(false); + }); + + it("binds visitor uploads to chat messages and returns attachment descriptors", async () => { + const attachment = await uploadSupportAttachment( + visitorClient, + { + workspaceId, + visitorId, + sessionToken: visitorSessionToken, + }, + "chat-attachment.png", + "image/png", + ONE_BY_ONE_PNG_BYTES + ); + + await visitorClient.mutation(api.messages.send, { + conversationId: visitorConversationId, + senderId: visitorId, + senderType: "visitor", + content: "", + attachmentIds: [attachment.attachmentId], + visitorId, + sessionToken: visitorSessionToken, + }); + + const messages = await visitorClient.query(api.messages.list, { + conversationId: visitorConversationId, + visitorId, + sessionToken: visitorSessionToken, + }); + const attachedMessage = messages.find((message) => (message.attachments?.length ?? 0) === 1); + + expect(attachedMessage).toBeDefined(); + expect(attachedMessage?.attachments).toHaveLength(1); + expect(attachedMessage?.attachments?.[0]?.fileName).toBe("chat-attachment.png"); + expect(attachedMessage?.attachments?.[0]?.url).toBeTruthy(); + }); + + it("rejects staged attachments uploaded by another visitor", async () => { + const attachment = await uploadSupportAttachment( + visitorClient, + { + workspaceId, + visitorId, + sessionToken: visitorSessionToken, + }, + "private-log.txt", + "text/plain", + TEXT_FILE_BYTES + ); + + await expect( + otherVisitorClient.mutation(api.messages.send, { + conversationId: otherConversationId, + senderId: otherVisitorId, + senderType: "visitor", + content: "Trying to reuse another visitor attachment", + attachmentIds: [attachment.attachmentId], + visitorId: otherVisitorId, + sessionToken: otherVisitorSessionToken, + }) + ).rejects.toThrow(/Not authorized/); + }); + + it("binds attachments to ticket submissions and agent replies", async () => { + const ticketAttachment = await uploadSupportAttachment( + visitorClient, + { + workspaceId, + visitorId, + sessionToken: visitorSessionToken, + }, + "ticket-context.png", + "image/png", + ONE_BY_ONE_PNG_BYTES + ); + + const ticketId = await visitorClient.mutation(api.tickets.create, { + workspaceId, + visitorId, + sessionToken: visitorSessionToken, + subject: "Attachment-enabled ticket", + description: "", + attachmentIds: [ticketAttachment.attachmentId], + }); + + const commentAttachment = await uploadSupportAttachment( + agentClient, + { workspaceId }, + "reply-note.txt", + "text/plain", + TEXT_FILE_BYTES + ); + + const commentId = await agentClient.mutation(api.tickets.addComment, { + ticketId, + content: "Attached a text note with the fix.", + attachmentIds: [commentAttachment.attachmentId], + isInternal: false, + authorId: userId, + authorType: "agent", + }); + + const ticketResult = await agentClient.query(api.tickets.getForAdminView, { + id: ticketId, + }); + + expect(ticketResult.status).toBe("ok"); + expect(ticketResult.ticket?.attachments).toHaveLength(1); + expect(ticketResult.ticket?.attachments?.[0]?.fileName).toBe("ticket-context.png"); + + const replyComment = ticketResult.ticket?.comments?.find((comment) => comment._id === commentId); + expect(replyComment?.attachments).toHaveLength(1); + expect(replyComment?.attachments?.[0]?.fileName).toBe("reply-note.txt"); + }); + + it("cleans up expired staged uploads and deletes the underlying stored file", async () => { + const attachment = await uploadSupportAttachment( + visitorClient, + { + workspaceId, + visitorId, + sessionToken: visitorSessionToken, + }, + "expired-upload.png", + "image/png", + ONE_BY_ONE_PNG_BYTES + ); + + await agentClient.mutation(api.testing_helpers.expireTestSupportAttachment, { + attachmentId: attachment.attachmentId, + }); + + const storedBeforeCleanup = await agentClient.mutation(api.testing_helpers.hasTestStoredFile, { + storageId: attachment.storageId, + }); + expect(storedBeforeCleanup).toBe(true); + + await agentClient.mutation(api.testing_helpers.cleanupExpiredSupportAttachments, { + limit: 10, + }); + + const deletedAttachment = await agentClient.mutation(api.testing_helpers.getTestSupportAttachment, { + attachmentId: attachment.attachmentId, + }); + const storedAfterCleanup = await agentClient.mutation(api.testing_helpers.hasTestStoredFile, { + storageId: attachment.storageId, + }); + + expect(deletedAttachment).toBeNull(); + expect(storedAfterCleanup).toBe(false); + }); +}); diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index d302f00..04d9a97 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -18,6 +18,17 @@ export { type ErrorFeedbackMessage, type NormalizeUnknownErrorOptions, } from "./errorFeedback"; +export { + SUPPORT_ATTACHMENT_ACCEPT, + formatSupportAttachmentSize, + getSupportAttachmentMimeType, + uploadSupportAttachments, + validateSupportAttachmentFiles, + type RejectedSupportAttachmentUpload, + type SupportAttachmentFinalizeResult, + type StagedSupportAttachment, + type SupportAttachmentDescriptor, +} from "./supportAttachments"; export { resolveArticleSourceId, type AISourceMetadata } from "./aiSourceLinks"; export { scoreSelectorQuality, diff --git a/packages/web-shared/src/supportAttachments.test.ts b/packages/web-shared/src/supportAttachments.test.ts new file mode 100644 index 0000000..308939f --- /dev/null +++ b/packages/web-shared/src/supportAttachments.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + getSupportAttachmentMimeType, + normalizeSupportAttachmentFileName, + validateSupportAttachmentFiles, +} from "./supportAttachments"; + +describe("support attachment validation", () => { + it("normalizes path-like file names before checking extensions", () => { + expect(normalizeSupportAttachmentFileName("C:\\fakepath\\invoice.PDF")).toBe("invoice.PDF"); + expect( + getSupportAttachmentMimeType({ + name: "C:\\fakepath\\invoice.PDF", + type: "application/pdf", + } as Pick) + ).toBe("application/pdf"); + }); + + it("rejects files whose extension is not allowlisted even if the browser type is allowed", () => { + expect( + validateSupportAttachmentFiles([ + { + name: "avatar.png.exe", + type: "image/png", + size: 1024, + } as File, + ]) + ).toMatchObject({ + message: 'Unsupported file type for "avatar.png.exe".', + }); + }); +}); diff --git a/packages/web-shared/src/supportAttachments.ts b/packages/web-shared/src/supportAttachments.ts new file mode 100644 index 0000000..e356e54 --- /dev/null +++ b/packages/web-shared/src/supportAttachments.ts @@ -0,0 +1,198 @@ +import type { ErrorFeedbackMessage } from "./errorFeedback"; + +export type SupportAttachmentDescriptor = { + _id: string; + fileName: string; + mimeType: string; + size: number; + url?: string; +}; + +export type StagedSupportAttachment = { + attachmentId: AttachmentId; + fileName: string; + mimeType: string; + size: number; + status: "staged"; +}; + +export type RejectedSupportAttachmentUpload = { + status: "rejected"; + message: string; +}; + +export type SupportAttachmentFinalizeResult = + | StagedSupportAttachment + | RejectedSupportAttachmentUpload; + +const MAX_SUPPORT_ATTACHMENT_BYTES = 10 * 1024 * 1024; +const MAX_SUPPORT_ATTACHMENTS_PER_PARENT = 5; + +const MIME_TYPE_BY_EXTENSION: Record = { + csv: "text/csv", + gif: "image/gif", + jpeg: "image/jpeg", + jpg: "image/jpeg", + json: "application/json", + pdf: "application/pdf", + png: "image/png", + txt: "text/plain", + webp: "image/webp", + zip: "application/zip", +}; + +export const SUPPORT_ATTACHMENT_ACCEPT = [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".pdf", + ".txt", + ".csv", + ".json", + ".zip", +].join(","); + +export function normalizeSupportAttachmentFileName(fileName: string): string { + const normalized = fileName.split(/[/\\]/).at(-1)?.trim(); + return normalized && normalized.length > 0 ? normalized : "attachment"; +} + +function inferMimeTypeFromFileName(fileName: string): string | null { + const extension = fileName.split(".").pop()?.trim().toLowerCase(); + if (!extension) { + return null; + } + return MIME_TYPE_BY_EXTENSION[extension] ?? null; +} + +export function getSupportAttachmentMimeType(file: Pick): string | null { + return inferMimeTypeFromFileName(normalizeSupportAttachmentFileName(file.name)); +} + +export function formatSupportAttachmentSize(size: number): string { + if (size >= 1024 * 1024) { + return `${(size / (1024 * 1024)).toFixed(size >= 10 * 1024 * 1024 ? 0 : 1)} MB`; + } + if (size >= 1024) { + return `${Math.max(1, Math.round(size / 1024))} KB`; + } + return `${size} B`; +} + +export function validateSupportAttachmentFiles( + files: readonly File[], + currentCount = 0 +): ErrorFeedbackMessage | null { + if (files.length === 0) { + return null; + } + + if (currentCount + files.length > MAX_SUPPORT_ATTACHMENTS_PER_PARENT) { + return { + message: `You can attach up to ${MAX_SUPPORT_ATTACHMENTS_PER_PARENT} files at a time.`, + nextAction: "Remove a file or send this message first, then upload again.", + }; + } + + for (const file of files) { + const mimeType = getSupportAttachmentMimeType(file); + if (!mimeType) { + return { + message: `Unsupported file type for "${file.name}".`, + nextAction: "Use PNG, JPG, GIF, WEBP, PDF, TXT, CSV, JSON, or ZIP files.", + }; + } + + if (file.size > MAX_SUPPORT_ATTACHMENT_BYTES) { + return { + message: `"${file.name}" is larger than 10 MB.`, + nextAction: "Choose a smaller file and try again.", + }; + } + } + + return null; +} + +type UploadUrlArgs = { + workspaceId: WorkspaceId; + visitorId?: VisitorId; + sessionToken?: string; +}; + +type FinalizeUploadArgs = UploadUrlArgs & { + storageId: StorageId; + fileName?: string; +}; + +type UploadedStoragePayload = { + storageId?: StorageId; +}; + +export async function uploadSupportAttachments< + WorkspaceId, + VisitorId, + StorageId, + AttachmentId, +>(args: { + files: readonly File[]; + currentCount?: number; + workspaceId: WorkspaceId; + visitorId?: VisitorId; + sessionToken?: string; + generateUploadUrl: (args: UploadUrlArgs) => Promise; + finalizeUpload: ( + args: FinalizeUploadArgs + ) => Promise>; + fetchImpl?: typeof fetch; +}): Promise[]> { + const validationError = validateSupportAttachmentFiles(args.files, args.currentCount ?? 0); + if (validationError) { + throw new Error(validationError.message); + } + + const fetchImpl = args.fetchImpl ?? fetch; + const uploadedAttachments: StagedSupportAttachment[] = []; + + for (const file of args.files) { + const mimeType = getSupportAttachmentMimeType(file); + if (!mimeType) { + throw new Error(`Unsupported file type for "${file.name}".`); + } + + const uploadUrl = await args.generateUploadUrl({ + workspaceId: args.workspaceId, + visitorId: args.visitorId, + sessionToken: args.sessionToken, + }); + const response = await fetchImpl(uploadUrl, { + method: "POST", + headers: { "Content-Type": mimeType }, + body: file, + }); + if (!response.ok) { + throw new Error(`Upload failed for "${file.name}".`); + } + + const payload = (await response.json()) as UploadedStoragePayload; + if (!payload.storageId) { + throw new Error(`Upload failed for "${file.name}".`); + } + + const attachment = await args.finalizeUpload({ + workspaceId: args.workspaceId, + visitorId: args.visitorId, + sessionToken: args.sessionToken, + storageId: payload.storageId, + fileName: file.name, + }); + if (attachment.status === "rejected") { + throw new Error(attachment.message); + } + uploadedAttachments.push(attachment); + } + + return uploadedAttachments; +}