From 4a6209fb304e4263e8b92c2e42cbe08fa89e1fe4 Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 12 Mar 2026 13:08:23 +0000 Subject: [PATCH 1/6] Add file uploads - UI needs work --- .../src/app/inbox/InboxThreadPane.test.tsx | 48 +++ apps/web/src/app/inbox/InboxThreadPane.tsx | 137 +++++- .../web/src/app/inbox/hooks/useInboxConvex.ts | 23 + .../app/inbox/hooks/useInboxMessageActions.ts | 40 +- apps/web/src/app/inbox/inboxRenderTypes.ts | 2 + apps/web/src/app/inbox/page.tsx | 71 ++- apps/web/src/app/tickets/[id]/page.tsx | 175 +++++++- .../src/app/tickets/hooks/useTicketsConvex.ts | 26 ++ apps/web/src/app/tickets/page.tsx | 163 ++++++- apps/widget/src/Widget.tsx | 16 + .../src/components/ConversationView.tsx | 72 +++- apps/widget/src/components/TicketCreate.tsx | 70 ++- .../src/components/TicketDetail.test.tsx | 72 ++++ apps/widget/src/components/TicketDetail.tsx | 145 ++++++- .../components/conversationView/Footer.tsx | 71 ++- .../conversationView/MessageList.tsx | 39 +- .../src/components/conversationView/types.ts | 2 + .../hooks/convex/useConversationViewConvex.ts | 29 ++ apps/widget/src/hooks/useWidgetTicketFlow.ts | 132 +++++- apps/widget/src/icons.tsx | 18 + apps/widget/src/styles.css | 169 ++++++++ .../add-chat-and-ticket-file-uploads/tasks.md | 24 +- packages/convex/convex/_generated/api.d.ts | 8 + packages/convex/convex/messages.ts | 62 ++- packages/convex/convex/schema.ts | 2 + .../convex/schema/inboxConversationTables.ts | 2 + .../convex/schema/outboundSupportTables.ts | 3 + .../convex/schema/supportAttachmentTables.ts | 30 ++ .../convex/convex/supportAttachmentTypes.ts | 33 ++ packages/convex/convex/supportAttachments.ts | 408 ++++++++++++++++++ packages/convex/convex/testData/cleanup.ts | 35 +- packages/convex/convex/testing/helpers.ts | 5 + .../convex/convex/testing/helpers/cleanup.ts | 24 +- .../testing/helpers/supportAttachments.ts | 74 ++++ packages/convex/convex/tickets.ts | 91 +++- .../tests/runtimeTypeHardeningGuard.test.ts | 12 + .../convex/tests/supportAttachments.test.ts | 333 ++++++++++++++ packages/web-shared/src/index.ts | 11 + packages/web-shared/src/supportAttachments.ts | 200 +++++++++ 39 files changed, 2787 insertions(+), 90 deletions(-) create mode 100644 apps/widget/src/components/TicketDetail.test.tsx create mode 100644 packages/convex/convex/schema/supportAttachmentTables.ts create mode 100644 packages/convex/convex/supportAttachmentTypes.ts create mode 100644 packages/convex/convex/supportAttachments.ts create mode 100644 packages/convex/convex/testing/helpers/supportAttachments.ts create mode 100644 packages/convex/tests/supportAttachments.test.ts create mode 100644 packages/web-shared/src/supportAttachments.ts diff --git a/apps/web/src/app/inbox/InboxThreadPane.test.tsx b/apps/web/src/app/inbox/InboxThreadPane.test.tsx index e1bdd9e..1a6467e 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 { 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..a875841 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, @@ -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, @@ -151,6 +163,8 @@ export function InboxThreadPane({ onInputChange, onInputKeyDown, onSendMessage, + onUploadAttachments = () => {}, + onRemovePendingAttachment = () => {}, onToggleKnowledgePicker, onKnowledgeSearchChange, onCloseKnowledgePicker, @@ -160,11 +174,21 @@ export function InboxThreadPane({ 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({ )}
+
+
- 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" - /> +
+ {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" + /> +
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.ts b/apps/web/src/app/inbox/hooks/useInboxMessageActions.ts index e36ce9a..30ee012 100644 --- a/apps/web/src/app/inbox/hooks/useInboxMessageActions.ts +++ b/apps/web/src/app/inbox/hooks/useInboxMessageActions.ts @@ -1,6 +1,7 @@ import { useCallback } from "react"; import type { Dispatch, SetStateAction } from "react"; import type { Id } from "@opencom/convex/dataModel"; +import type { StagedSupportAttachment } from "@opencom/web-shared"; type ConversationStatus = "open" | "closed" | "snoozed"; @@ -24,6 +25,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 +33,11 @@ interface MutationApi { interface MutationState { inputValue: string; + pendingAttachments: StagedSupportAttachment>[]; setInputValue: Dispatch>; + setPendingAttachments: Dispatch< + SetStateAction>[]> + >; setIsSending: Dispatch>; setIsResolving: Dispatch>; setIsConvertingTicket: Dispatch>; @@ -68,6 +74,20 @@ export function useInboxMessageActions({ 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 +125,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 +145,7 @@ export function useInboxMessageActions({ patchConversationState(conversationId, { unreadByAgent: 0, lastMessageAt: now, - optimisticLastMessage: content, + optimisticLastMessage: getOptimisticLastMessage(content), }); try { @@ -129,7 +154,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 +173,14 @@ export function useInboxMessageActions({ } finally { state.setIsSending(false); } - }, [api, context.selectedConversationId, context.userId, patchConversationState, state]); + }, [ + api, + 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..fbab793 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"; @@ -75,6 +80,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); @@ -105,6 +114,8 @@ function InboxContent(): React.JSX.Element | null { conversationsData, createSnippet, convertToTicket, + finalizeSupportAttachmentUpload, + generateSupportAttachmentUploadUrl, getSuggestionsForConversation, knowledgeResults, markAsRead, @@ -220,6 +231,10 @@ function InboxContent(): React.JSX.Element | null { return () => document.removeEventListener("keydown", handleGlobalKeyDown); }, []); + useEffect(() => { + setPendingAttachments([]); + }, [selectedConversationId]); + useEffect(() => { if (!selectedConversationId || !messages || messages.length === 0) { return; @@ -230,7 +245,10 @@ function InboxContent(): React.JSX.Element | null { if (!patch?.optimisticLastMessage) { return previousState; } - if (latestMessage.content !== patch.optimisticLastMessage) { + if ( + latestMessage.senderType !== "agent" || + latestMessage.createdAt < (patch.lastMessageAt ?? 0) + ) { return previousState; } @@ -291,7 +309,9 @@ function InboxContent(): React.JSX.Element | null { }, state: { inputValue, + pendingAttachments, setInputValue, + setPendingAttachments, setIsSending, setIsResolving, setIsConvertingTicket, @@ -440,6 +460,49 @@ 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; + } + + setWorkflowError(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) { + setWorkflowError( + normalizeUnknownError(error, { + fallbackMessage: "Failed to upload attachment.", + nextAction: "Try again with a supported file.", + }).message + ); + } finally { + 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 +539,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 +583,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..bf13395 100644 --- a/apps/web/src/app/tickets/[id]/page.tsx +++ b/apps/web/src/app/tickets/[id]/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 { ArrowLeft, @@ -12,6 +19,8 @@ import { Lock, Unlock, MessageSquare, + Paperclip, + X, } from "lucide-react"; import type { Id } from "@opencom/convex/dataModel"; import { useAuth } from "@/contexts/AuthContext"; @@ -54,8 +63,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 +109,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,12 +274,47 @@ 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 && (

Description

{ticket.description}

+ {ticket.attachments && ticket.attachments.length > 0 && ( + + )}
)} @@ -254,6 +348,27 @@ function TicketDetailContent(): React.JSX.Element | null {

{comment.content}

+ {comment.attachments && comment.attachments.length > 0 && ( + + )}
))} @@ -265,6 +380,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 +460,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.tsx b/apps/widget/src/components/conversationView/Footer.tsx index de4816d..9a1a98d 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,6 +212,52 @@ export function ConversationFooter({ )}
+ {composerError &&
{composerError}
} + { + const files = Array.from(event.target.files ?? []); + if (files.length > 0) { + void onUploadAttachments(files); + } + event.target.value = ""; + }} + /> + + {pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map((attachment) => ( +
+ + + {attachment.fileName} + + + {formatSupportAttachmentSize(attachment.size)} + + +
+ ))} +
+ )} diff --git a/apps/widget/src/components/conversationView/MessageList.tsx b/apps/widget/src/components/conversationView/MessageList.tsx index db77926..73b060d 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"; @@ -81,12 +81,35 @@ export function ConversationMessageList({ {humanAgentName} )} -
+ {msg.content.trim().length > 0 && ( +
+ )} + {msg.attachments && msg.attachments.length > 0 && ( + + )} {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 ( & { 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/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..e919b3a --- /dev/null +++ b/packages/convex/convex/schema/supportAttachmentTables.ts @@ -0,0 +1,30 @@ +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.optional(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"]), +}; 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..e82b27a --- /dev/null +++ b/packages/convex/convex/supportAttachments.ts @@ -0,0 +1,408 @@ +import { makeFunctionReference, type FunctionReference } from "convex/server"; +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_MIME_TYPES, + SUPPORTED_SUPPORT_ATTACHMENT_TYPE_LABEL, +} from "./supportAttachmentTypes"; +import { createError } from "./utils/errors"; +import { resolveVisitorFromSession } from "./widgetSessions"; + +type SupportAttachmentWorkspaceArgs = { + workspaceId: Id<"workspaces">; + visitorId?: Id<"visitors">; + sessionToken?: string; +}; + +type SupportAttachmentActor = + | { accessType: "agent"; userId: Id<"users"> } + | { accessType: "visitor"; visitorId: Id<"visitors"> }; + +type CleanupExpiredStagedUploadsArgs = { + limit?: number; +}; + +type CleanupExpiredStagedUploadsResult = { + deleted: number; + hasMore: boolean; +}; + +type InternalMutationRef, Return = unknown> = + FunctionReference<"mutation", "internal", Args, Return>; + +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_EXPIRED_STAGED_UPLOADS_REF = makeFunctionReference< + "mutation", + CleanupExpiredStagedUploadsArgs, + CleanupExpiredStagedUploadsResult +>("supportAttachments:cleanupExpiredStagedUploads") as unknown as InternalMutationRef< + CleanupExpiredStagedUploadsArgs, + CleanupExpiredStagedUploadsResult +>; + +function getShallowRunAfter(ctx: { scheduler: { runAfter: unknown } }) { + return ctx.scheduler.runAfter as unknown as < + Args extends Record, + Return = unknown, + >( + delayMs: number, + functionRef: InternalMutationRef, + runArgs: Args + ) => Promise; +} + +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 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 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 metadata = await ctx.storage.getMetadata(args.storageId); + if (!metadata) { + throw createError("NOT_FOUND", "Uploaded file not found"); + } + + const mimeType = metadata.contentType?.trim() ?? ""; + if (!SUPPORTED_SUPPORT_ATTACHMENT_MIME_TYPES.has(mimeType)) { + await deleteUploadedFileIfPresent(ctx, args.storageId); + return { + status: "rejected" as const, + message: `Unsupported file type. Allowed: ${SUPPORTED_SUPPORT_ATTACHMENT_TYPE_LABEL}.`, + }; + } + + 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: normalizeAttachmentFileName(args.fileName), + mimeType, + size: metadata.size, + status: "staged", + uploadedByType: actor.accessType, + uploadedById: getActorUploadId(actor), + createdAt: now, + expiresAt: now + STAGED_SUPPORT_ATTACHMENT_TTL_MS, + }); + + const runAfter = getShallowRunAfter(ctx); + await runAfter(STAGED_SUPPORT_ATTACHMENT_TTL_MS + 1_000, CLEANUP_EXPIRED_STAGED_UPLOADS_REF, { + limit: DEFAULT_CLEANUP_LIMIT, + }); + + return { + status: "staged" as const, + attachmentId, + fileName: normalizeAttachmentFileName(args.fileName), + mimeType, + size: metadata.size, + }; + }, +}); + +export const cleanupExpiredStagedUploads = internalMutation({ + args: { + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + 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_status_expires", (q) => + q.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; + } + + 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..f1a8a47 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,17 @@ 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).toContain("CLEANUP_EXPIRED_STAGED_UPLOADS_REF"); + expect(supportAttachmentsSource).toContain("getShallowRunAfter"); + }); + 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..1580eec --- /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 unsupported uploads 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": "application/octet-stream" }, + 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.ts b/packages/web-shared/src/supportAttachments.ts new file mode 100644 index 0000000..533b09c --- /dev/null +++ b/packages/web-shared/src/supportAttachments.ts @@ -0,0 +1,200 @@ +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", +}; + +const SUPPORTED_SUPPORT_ATTACHMENT_MIME_TYPES = new Set(Object.values(MIME_TYPE_BY_EXTENSION)); +SUPPORTED_SUPPORT_ATTACHMENT_MIME_TYPES.add("application/x-zip-compressed"); + +export const SUPPORT_ATTACHMENT_ACCEPT = [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".pdf", + ".txt", + ".csv", + ".json", + ".zip", +].join(","); + +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 { + const normalizedType = file.type?.trim().toLowerCase(); + if (normalizedType && SUPPORTED_SUPPORT_ATTACHMENT_MIME_TYPES.has(normalizedType)) { + return normalizedType; + } + return inferMimeTypeFromFileName(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; +} From aecaba5130ef77ca10091b4e6bad61de1fcb93a1 Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 12 Mar 2026 13:25:49 +0000 Subject: [PATCH 2/6] improve styling, remove Snippets button --- apps/web/src/app/inbox/InboxThreadPane.tsx | 20 ++--- .../conversationView/Footer.test.tsx | 76 +++++++++++++++++++ .../components/conversationView/Footer.tsx | 62 +++++++-------- apps/widget/src/styles.css | 32 ++++++-- 4 files changed, 144 insertions(+), 46 deletions(-) create mode 100644 apps/widget/src/components/conversationView/Footer.test.tsx diff --git a/apps/web/src/app/inbox/InboxThreadPane.tsx b/apps/web/src/app/inbox/InboxThreadPane.tsx index a875841..caa464b 100644 --- a/apps/web/src/app/inbox/InboxThreadPane.tsx +++ b/apps/web/src/app/inbox/InboxThreadPane.tsx @@ -22,7 +22,7 @@ import { ShieldAlert, Ticket, X, - Zap, + // Zap, } from "lucide-react"; import type { Id } from "@opencom/convex/dataModel"; import { @@ -150,9 +150,9 @@ export function InboxThreadPane({ isSidecarEnabled, suggestionsCount, isSuggestionsCountLoading, - canSaveDraftAsSnippet, - canUpdateSnippetFromDraft, - lastInsertedSnippetName, + // canSaveDraftAsSnippet, + // canUpdateSnippetFromDraft, + // lastInsertedSnippetName, replyInputRef, onBackToList, onResolveConversation, @@ -169,8 +169,8 @@ export function InboxThreadPane({ onKnowledgeSearchChange, onCloseKnowledgePicker, onInsertKnowledgeContent, - onSaveDraftAsSnippet, - onUpdateSnippetFromDraft, + // onSaveDraftAsSnippet, + // onUpdateSnippetFromDraft, getConversationIdentityLabel, getHandoffReasonLabel, }: InboxThreadPaneProps): React.JSX.Element { @@ -659,7 +659,7 @@ export function InboxThreadPane({ > - - {canUpdateSnippetFromDraft ? ( + */} + {/* {canUpdateSnippetFromDraft ? ( - ) : null} + ) : null} */}
{pendingAttachments.length > 0 && ( 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 9a1a98d..6fe3bfe 100644 --- a/apps/widget/src/components/conversationView/Footer.tsx +++ b/apps/widget/src/components/conversationView/Footer.tsx @@ -227,15 +227,6 @@ export function ConversationFooter({ event.target.value = ""; }} /> - {pendingAttachments.length > 0 && (
{pendingAttachments.map((attachment) => ( @@ -258,27 +249,38 @@ export function ConversationFooter({ ))}
)} - onInputChange(e.target.value)} - onKeyDown={onInputKeyDown} - placeholder="Type a message..." - className="opencom-input" - data-testid="widget-message-input" - disabled={isUploadingAttachments} - /> - +
+ + 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/styles.css b/apps/widget/src/styles.css index 4e4446b..aff7e37 100644 --- a/apps/widget/src/styles.css +++ b/apps/widget/src/styles.css @@ -457,7 +457,7 @@ .opencom-input-container { padding: 10px; display: flex; - flex-wrap: wrap; + flex-direction: column; gap: 8px; background: var(--opencom-bg-surface); } @@ -474,8 +474,16 @@ border: 0; } +.opencom-composer-row { + width: 100%; + display: flex; + align-items: center; + gap: 8px; +} + .opencom-input { flex: 1; + min-width: 0; padding: 12px 16px; border: 2px solid var(--opencom-border-color); border-radius: 24px; @@ -503,6 +511,7 @@ } .opencom-send { + flex-shrink: 0; width: 44px; height: 44px; border-radius: 50%; @@ -518,6 +527,7 @@ } .opencom-attach { + flex-shrink: 0; width: 44px; height: 44px; border-radius: 50%; @@ -545,25 +555,30 @@ .opencom-pending-attachments { width: 100%; display: flex; - flex-wrap: wrap; + flex-direction: column; gap: 8px; + max-height: 144px; + overflow-y: auto; + padding-right: 4px; } .opencom-pending-attachment { - display: inline-flex; + width: 100%; + display: flex; align-items: center; - gap: 6px; - max-width: 100%; + gap: 8px; padding: 6px 10px; border-radius: 999px; border: 1px solid var(--opencom-border-color); background: var(--opencom-bg-muted); font-size: 12px; color: var(--opencom-text-color); + box-sizing: border-box; } .opencom-pending-attachment-name, .opencom-message-attachment-name { + flex: 1; min-width: 0; display: inline-flex; align-items: center; @@ -573,6 +588,11 @@ white-space: nowrap; } +.opencom-pending-attachment-name svg, +.opencom-message-attachment-name svg { + flex-shrink: 0; +} + .opencom-pending-attachment-size, .opencom-message-attachment-size { flex-shrink: 0; @@ -611,7 +631,7 @@ border-radius: 10px; border: 1px solid color-mix(in srgb, var(--opencom-border-color) 90%, transparent); background: color-mix(in srgb, var(--opencom-bg-surface) 92%, transparent); - color: inherit; + color: var(--opencom-text-color); text-decoration: none; } From a9395779967a932be527d8d8db7dc1a1a5e1ad84 Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 12 Mar 2026 13:41:05 +0000 Subject: [PATCH 3/6] Update unit tests --- .../src/app/inbox/InboxThreadPane.test.tsx | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/inbox/InboxThreadPane.test.tsx b/apps/web/src/app/inbox/InboxThreadPane.test.tsx index 1a6467e..964f773 100644 --- a/apps/web/src/app/inbox/InboxThreadPane.test.tsx +++ b/apps/web/src/app/inbox/InboxThreadPane.test.tsx @@ -130,14 +130,16 @@ 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( @@ -146,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", () => { From 932cd43ad400b4e303b8e56aa3f0a082b59345d9 Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 12 Mar 2026 14:19:08 +0000 Subject: [PATCH 4/6] skip dismiss email collection test --- apps/web/e2e/widget.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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); From babe70a62ad65b48d8b6eca1a2acad1b3e57f5ef Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 12 Mar 2026 17:48:15 +0000 Subject: [PATCH 5/6] fallback and tweaks --- apps/web/src/app/inbox/page.tsx | 29 +++- apps/web/src/app/tickets/[id]/page.tsx | 93 ++++++----- .../src/components/TicketDetail.test.tsx | 52 ++++++ apps/widget/src/components/TicketDetail.tsx | 90 ++++++----- .../conversationView/MessageList.test.tsx | 45 ++++++ .../conversationView/MessageList.tsx | 56 +++++-- apps/widget/src/styles.css | 9 ++ .../.openspec.yaml | 2 + .../design.md | 151 ++++++++++++++++++ .../proposal.md | 26 +++ .../spec.md | 77 +++++++++ .../tasks.md | 23 +++ packages/convex/convex/_generated/api.d.ts | 2 + .../convex/schema/authWorkspaceTables.ts | 1 + .../convex/schema/supportAttachmentTables.ts | 5 +- .../convex/supportAttachmentFunctionRefs.ts | 25 +++ packages/convex/convex/supportAttachments.ts | 144 ++++++++++++----- .../tests/runtimeTypeHardeningGuard.test.ts | 5 +- 18 files changed, 696 insertions(+), 139 deletions(-) create mode 100644 apps/widget/src/components/conversationView/MessageList.test.tsx create mode 100644 openspec/changes/add-mobile-and-rn-sdk-file-uploads/.openspec.yaml create mode 100644 openspec/changes/add-mobile-and-rn-sdk-file-uploads/design.md create mode 100644 openspec/changes/add-mobile-and-rn-sdk-file-uploads/proposal.md create mode 100644 openspec/changes/add-mobile-and-rn-sdk-file-uploads/specs/mobile-and-rn-sdk-file-attachments/spec.md create mode 100644 openspec/changes/add-mobile-and-rn-sdk-file-uploads/tasks.md create mode 100644 packages/convex/convex/supportAttachmentFunctionRefs.ts diff --git a/apps/web/src/app/inbox/page.tsx b/apps/web/src/app/inbox/page.tsx index fbab793..aee3082 100644 --- a/apps/web/src/app/inbox/page.tsx +++ b/apps/web/src/app/inbox/page.tsx @@ -107,6 +107,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, @@ -232,7 +236,10 @@ function InboxContent(): React.JSX.Element | null { }, []); useEffect(() => { + attachmentUploadContextVersionRef.current += 1; + attachmentUploadRequestIdRef.current += 1; setPendingAttachments([]); + setIsUploadingAttachments(false); }, [selectedConversationId]); useEffect(() => { @@ -466,6 +473,10 @@ function InboxContent(): React.JSX.Element | null { return; } + const uploadConversationId = selectedConversationId; + const uploadContextVersion = attachmentUploadContextVersionRef.current; + const uploadRequestId = attachmentUploadRequestIdRef.current + 1; + attachmentUploadRequestIdRef.current = uploadRequestId; setWorkflowError(null); setIsUploadingAttachments(true); try { @@ -476,8 +487,22 @@ function InboxContent(): React.JSX.Element | null { 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.", @@ -485,7 +510,9 @@ function InboxContent(): React.JSX.Element | null { }).message ); } finally { - setIsUploadingAttachments(false); + if (attachmentUploadRequestIdRef.current === uploadRequestId) { + setIsUploadingAttachments(false); + } } }, [ diff --git a/apps/web/src/app/tickets/[id]/page.tsx b/apps/web/src/app/tickets/[id]/page.tsx index bf13395..b14adb7 100644 --- a/apps/web/src/app/tickets/[id]/page.tsx +++ b/apps/web/src/app/tickets/[id]/page.tsx @@ -7,6 +7,7 @@ import { normalizeUnknownError, uploadSupportAttachments, type StagedSupportAttachment, + type SupportAttachmentDescriptor, } from "@opencom/web-shared"; import { Button, Card, Input } from "@opencom/ui"; import { @@ -54,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(); @@ -294,27 +333,15 @@ function TicketDetailContent(): React.JSX.Element | null {

Description

{ticket.description}

- {ticket.attachments && ticket.attachments.length > 0 && ( - - )} +
+ )} + + {ticket.attachments && ticket.attachments.length > 0 && ( +
+

Attachments

+
+ {ticket.attachments.map((attachment) => renderAttachmentRow(attachment))} +
)} @@ -347,32 +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) => ( - - - - {attachment.fileName} - - - {formatSupportAttachmentSize(attachment.size)} - - - ))} + {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

)}
diff --git a/apps/widget/src/components/TicketDetail.test.tsx b/apps/widget/src/components/TicketDetail.test.tsx index b4592e1..959aa1f 100644 --- a/apps/widget/src/components/TicketDetail.test.tsx +++ b/apps/widget/src/components/TicketDetail.test.tsx @@ -69,4 +69,56 @@ describe("TicketDetail attachments", () => { "pending-attachment-1" as Id<"supportAttachments"> ); }); + + it("shows root attachments for attachment-only tickets and disables missing attachment URLs", () => { + render( + + ); + + expect(screen.getByText("Attachments")).toBeInTheDocument(); + expect(screen.getByText("missing-url.png")).toBeInTheDocument(); + expect(screen.getByText("missing-log.txt")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /missing-url\.png/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /missing-log\.txt/i })).not.toBeInTheDocument(); + expect(screen.getByText("missing-url.png").closest("[aria-disabled='true']")).toBeTruthy(); + expect(screen.getByText("missing-log.txt").closest("[aria-disabled='true']")).toBeTruthy(); + }); }); diff --git a/apps/widget/src/components/TicketDetail.tsx b/apps/widget/src/components/TicketDetail.tsx index 2cecaf6..195db71 100644 --- a/apps/widget/src/components/TicketDetail.tsx +++ b/apps/widget/src/components/TicketDetail.tsx @@ -70,6 +70,44 @@ function getTicketStatusClass(status: string): string { } } +function renderAttachmentRow(attachment: SupportAttachmentDescriptor) { + const content = ( + <> + + + {attachment.fileName} + + + {formatSupportAttachmentSize(attachment.size)} + + + ); + + if (attachment.url) { + return ( + + {content} + + ); + } + + return ( +
+ {content} +
+ ); +} + export function TicketDetail({ ticket, onBack, @@ -133,27 +171,15 @@ export function TicketDetail({ {ticket.description && (
{ticket.description} - {ticket.attachments && ticket.attachments.length > 0 && ( - - )} +
+ )} + + {ticket.attachments && ticket.attachments.length > 0 && ( +
+ Attachments +
+ {ticket.attachments.map((attachment) => renderAttachmentRow(attachment))} +
)} @@ -172,26 +198,12 @@ export function TicketDetail({ ? "System" : "Support"}
-
{comment.content}
+ {comment.content.trim().length > 0 && ( +
{comment.content}
+ )} {comment.attachments && comment.attachments.length > 0 && (
- {comment.attachments.map((attachment) => ( - - - - {attachment.fileName} - - - {formatSupportAttachmentSize(attachment.size)} - - - ))} + {comment.attachments.map((attachment) => renderAttachmentRow(attachment))}
)}
{formatTime(comment.createdAt)}
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 73b060d..7d39dd2 100644 --- a/apps/widget/src/components/conversationView/MessageList.tsx +++ b/apps/widget/src/components/conversationView/MessageList.tsx @@ -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, @@ -91,23 +129,7 @@ export function ConversationMessageList({ )} {msg.attachments && msg.attachments.length > 0 && (
- {msg.attachments.map((attachment) => ( - - - - {attachment.fileName} - - - {formatSupportAttachmentSize(attachment.size)} - - - ))} + {msg.attachments.map((attachment) => renderAttachmentRow(attachment))}
)} {isAi && aiData && ( diff --git a/apps/widget/src/styles.css b/apps/widget/src/styles.css index aff7e37..bb0aa70 100644 --- a/apps/widget/src/styles.css +++ b/apps/widget/src/styles.css @@ -635,10 +635,19 @@ text-decoration: none; } +.opencom-message-attachment-disabled { + cursor: default; + opacity: 0.84; +} + .opencom-message-attachment:hover { border-color: color-mix(in srgb, var(--opencom-primary-color) 24%, var(--opencom-border-color)); } +.opencom-message-attachment-disabled:hover { + border-color: color-mix(in srgb, var(--opencom-border-color) 90%, transparent); +} + .opencom-send:hover { transform: scale(1.05); box-shadow: 0 4px 10px rgba(121, 44, 212, 0.4); diff --git a/openspec/changes/add-mobile-and-rn-sdk-file-uploads/.openspec.yaml b/openspec/changes/add-mobile-and-rn-sdk-file-uploads/.openspec.yaml new file mode 100644 index 0000000..6dfce10 --- /dev/null +++ b/openspec/changes/add-mobile-and-rn-sdk-file-uploads/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-12 diff --git a/openspec/changes/add-mobile-and-rn-sdk-file-uploads/design.md b/openspec/changes/add-mobile-and-rn-sdk-file-uploads/design.md new file mode 100644 index 0000000..c26fb6c --- /dev/null +++ b/openspec/changes/add-mobile-and-rn-sdk-file-uploads/design.md @@ -0,0 +1,151 @@ +## Context + +The backend support-attachment domain now exists in Convex and already powers web inbox, web tickets, and widget uploads. React Native surfaces have not adopted that model yet: + +- `packages/react-native-sdk` messenger and ticket hooks still send only text content and do not expose attachment metadata. +- `packages/react-native-sdk` compose/detail components have no file picker affordance, upload orchestration, or attachment rendering. +- `apps/mobile` currently exposes an agent conversation screen backed by direct Convex wrapper hooks, but that screen is also text-only. + +This change crosses public SDK contracts, shared transport helpers, and first-party mobile UI. The main constraints are preserving the existing Convex support-attachment lifecycle, keeping the RN SDK usable outside a single Expo app shell, and extending hardening coverage so new native upload boundaries do not bypass the current Convex adapter rules. + +## Goals / Non-Goals + +**Goals:** + +- Add attachment upload, send, and rendering support to the React Native SDK messenger conversation flow. +- Add attachment support to React Native SDK ticket creation and ticket reply flows. +- Add agent-side attachment support to the first-party mobile app's existing conversation screen. +- Reuse the existing Convex staged-upload and secure download model instead of inventing a mobile-only storage path. +- Keep file picking and upload boundaries explicit so host apps and tests can control them safely. + +**Non-Goals:** + +- Replacing or redesigning the existing Convex support attachment backend model. +- Adding first-party mobile ticket surfaces that do not already exist in `apps/mobile`. +- Shipping rich native preview generation, annotation, or offline attachment sync. +- Guaranteeing a built-in picker implementation for every possible React Native host environment in v1. +- Expanding the imperative `OpencomSDK` static API unless attachment support there is required during implementation. + +## Decisions + +### 1) Add a React Native-specific attachment client layer instead of reusing the browser upload helper directly + +Decision: + +- Introduce a React Native attachment helper layer in `packages/react-native-sdk` that normalizes picked files into a platform-safe local descriptor such as `uri`, `fileName`, `mimeType`, and `size`. +- Reuse the existing Convex staged-upload flow and attachment descriptor shape, but keep React Native upload orchestration separate from `packages/web-shared/src/supportAttachments.ts`, which is browser-`File` based. + +Rationale: + +- The existing browser helper assumes DOM `File` objects and browser upload semantics that are not a safe fit for React Native/native file URIs. +- A dedicated RN helper keeps native concerns local while preserving backend parity with web/widget behavior. + +Alternatives considered: + +- Reuse the browser helper directly: rejected because it couples RN code to DOM-only types and upload assumptions. +- Reimplement upload orchestration independently in each RN component: rejected because it would duplicate validation, error handling, and attachment state management. + +### 2) Put file selection behind an explicit picker adapter boundary for the RN SDK + +Decision: + +- Extend the RN SDK configuration/context with an attachment picker contract that host apps can supply. +- The built-in SDK messenger/ticket components will only expose upload affordances when a picker adapter is available. +- The first-party mobile app will provide an Expo-backed picker implementation for its own screens and any SDK usage it owns. + +Rationale: + +- The SDK is a reusable package, while first-party mobile is an Expo app. An adapter boundary lets us support the Expo path without baking Expo-only document-picker assumptions into every SDK consumer. +- This keeps failure modes predictable: host apps without picker support remain text-capable instead of crashing on missing native modules. + +Alternatives considered: + +- Hard-depend on `expo-document-picker` inside the RN SDK: rejected because it over-couples the SDK to Expo runtime assumptions. +- Leave attachment picking entirely to host-defined custom UI: rejected because the built-in Opencom messenger/ticket components would still lack first-class attachment support. + +### 3) Extend RN SDK hooks/controllers and mobile Convex wrappers with attachment-aware contracts + +Decision: + +- Update RN SDK conversation/ticket query result types to include attachment descriptors returned by backend reads. +- Extend RN SDK send/create/reply flows so controller logic can bind staged attachment IDs alongside message or ticket content. +- Extend `apps/mobile` Convex wrapper types and send-message arguments so the first-party agent conversation screen can render attachment metadata and send attachment IDs. + +Rationale: + +- Attachment support is not just a UI concern; the current hook/controller contracts only model text and would otherwise force components to bypass the existing transport boundaries. +- Keeping attachment-aware reads and writes in the established adapter layers preserves the repo's Convex hardening rules. + +Alternatives considered: + +- Add standalone upload-only helpers and keep hooks text-only: rejected because it would split one user action across unrelated public APIs. +- Let first-party mobile call backend attachment functions directly from screens: rejected because it would bypass the wrapper pattern the app already uses for Convex access. + +### 4) Keep rendering download-first and compact on native surfaces + +Decision: + +- RN SDK messenger, ticket create/detail, and first-party mobile conversation UIs will render attachments as compact rows/chips with filename, size, removal state, and secure open/download actions. +- Inline previews, if any, should be limited to safe lightweight cases discovered during implementation; the baseline experience is explicit attachment rows. + +Rationale: + +- Native layouts are smaller and more variable than web surfaces, so a download-first presentation is the lowest-risk way to achieve parity quickly. +- This matches the secure URL model already returned by the backend without requiring new preview pipelines. + +Alternatives considered: + +- Build image-gallery style previews for all image uploads in v1: rejected because it adds platform-specific complexity without being required for support workflows. +- Hide attachments in historical threads and only support upload on send: rejected because attachment parity would be incomplete for recipients. + +### 5) Treat boundary and contract tests as part of the feature, not follow-up cleanup + +Decision: + +- Update RN SDK hardening/contract tests for new attachment transport helpers and public component contracts. +- Update mobile hardening coverage for any new Convex wrapper hooks or ref factories introduced for attachment support. +- Add focused UI/controller tests that cover attachment queueing, validation feedback, and attachment rendering. + +Rationale: + +- This feature adds new public API surface and native-side upload orchestration, which is exactly where boundary regressions are most likely. +- Existing repo guidance explicitly expects corresponding hardening guards to move with changed boundaries. + +Alternatives considered: + +- Rely only on manual testing after implementation: rejected because the risk surface spans auth, native file inputs, and public SDK contracts. + +## Risks / Trade-offs + +- [Risk] Host apps may not provide an attachment picker adapter, leaving built-in SDK upload UI unavailable. + -> 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 e78b830..ee164bd 100644 --- a/packages/convex/convex/_generated/api.d.ts +++ b/packages/convex/convex/_generated/api.d.ts @@ -128,6 +128,7 @@ 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"; @@ -312,6 +313,7 @@ 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; 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/supportAttachmentTables.ts b/packages/convex/convex/schema/supportAttachmentTables.ts index e919b3a..079641e 100644 --- a/packages/convex/convex/schema/supportAttachmentTables.ts +++ b/packages/convex/convex/schema/supportAttachmentTables.ts @@ -17,7 +17,7 @@ export const supportAttachmentTables = { ticketId: v.optional(v.id("tickets")), ticketCommentId: v.optional(v.id("ticketComments")), uploadedByType: supportAttachmentUploaderTypeValidator, - uploadedById: v.optional(v.string()), + uploadedById: v.string(), createdAt: v.number(), attachedAt: v.optional(v.number()), expiresAt: v.optional(v.number()), @@ -26,5 +26,6 @@ export const supportAttachmentTables = { .index("by_message", ["messageId"]) .index("by_ticket", ["ticketId"]) .index("by_ticket_comment", ["ticketCommentId"]) - .index("by_status_expires", ["status", "expiresAt"]), + .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/supportAttachments.ts b/packages/convex/convex/supportAttachments.ts index e82b27a..5bc8482 100644 --- a/packages/convex/convex/supportAttachments.ts +++ b/packages/convex/convex/supportAttachments.ts @@ -1,4 +1,3 @@ -import { makeFunctionReference, type FunctionReference } from "convex/server"; import { v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import { @@ -18,6 +17,11 @@ import { } 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">; @@ -29,18 +33,6 @@ type SupportAttachmentActor = | { accessType: "agent"; userId: Id<"users"> } | { accessType: "visitor"; visitorId: Id<"visitors"> }; -type CleanupExpiredStagedUploadsArgs = { - limit?: number; -}; - -type CleanupExpiredStagedUploadsResult = { - deleted: number; - hasMore: boolean; -}; - -type InternalMutationRef, Return = unknown> = - FunctionReference<"mutation", "internal", Args, Return>; - export type SupportAttachmentDescriptor = { _id: Id<"supportAttachments">; fileName: string; @@ -51,26 +43,8 @@ export type SupportAttachmentDescriptor = { const DEFAULT_CLEANUP_LIMIT = 100; const MAX_CLEANUP_LIMIT = 500; - -const CLEANUP_EXPIRED_STAGED_UPLOADS_REF = makeFunctionReference< - "mutation", - CleanupExpiredStagedUploadsArgs, - CleanupExpiredStagedUploadsResult ->("supportAttachments:cleanupExpiredStagedUploads") as unknown as InternalMutationRef< - CleanupExpiredStagedUploadsArgs, - CleanupExpiredStagedUploadsResult ->; - -function getShallowRunAfter(ctx: { scheduler: { runAfter: unknown } }) { - return ctx.scheduler.runAfter as unknown as < - Args extends Record, - Return = unknown, - >( - delayMs: number, - functionRef: InternalMutationRef, - runArgs: Args - ) => Promise; -} +const CLEANUP_SCHEDULE_GRACE_MS = 1_000; +const CLEANUP_EXPIRED_STAGED_UPLOADS_REF = cleanupExpiredStagedUploadsRef; function normalizeAttachmentIds( attachmentIds?: readonly Id<"supportAttachments">[] @@ -143,6 +117,60 @@ async function deleteUploadedFileIfPresent( 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", @@ -360,10 +388,11 @@ export const finalizeUpload = mutation({ expiresAt: now + STAGED_SUPPORT_ATTACHMENT_TTL_MS, }); - const runAfter = getShallowRunAfter(ctx); - await runAfter(STAGED_SUPPORT_ATTACHMENT_TTL_MS + 1_000, CLEANUP_EXPIRED_STAGED_UPLOADS_REF, { - limit: DEFAULT_CLEANUP_LIMIT, - }); + await ensureSupportAttachmentCleanupScheduled( + ctx, + args.workspaceId, + getSupportAttachmentCleanupScheduledAt(now + STAGED_SUPPORT_ATTACHMENT_TTL_MS) + ); return { status: "staged" as const, @@ -377,9 +406,25 @@ export const finalizeUpload = mutation({ 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, @@ -388,8 +433,8 @@ export const cleanupExpiredStagedUploads = internalMutation({ const expiredUploads = await ctx.db .query("supportAttachments") - .withIndex("by_status_expires", (q) => - q.eq("status", "staged").lt("expiresAt", now) + .withIndex("by_workspace_status_expires", (q) => + q.eq("workspaceId", args.workspaceId).eq("status", "staged").lt("expiresAt", now) ) .take(limit); @@ -400,6 +445,27 @@ export const cleanupExpiredStagedUploads = internalMutation({ 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/tests/runtimeTypeHardeningGuard.test.ts b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts index f1a8a47..a059707 100644 --- a/packages/convex/tests/runtimeTypeHardeningGuard.test.ts +++ b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts @@ -143,8 +143,11 @@ describe("runtime type hardening guards", () => { ); 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("getShallowRunAfter"); + expect(supportAttachmentsSource).toContain('from "./supportAttachmentFunctionRefs"'); + expect(supportAttachmentsSource).toContain('from "./notifications/functionRefs"'); }); it("uses fixed typed refs for HTTP origin validation", () => { From e2160558481e90ef6d76bbe83fbd269bff10ac6b Mon Sep 17 00:00:00 2001 From: Jack D Date: Thu, 12 Mar 2026 18:17:33 +0000 Subject: [PATCH 6/6] Limit filetypes Partially completed - the upload spoofing hardening is improved, but not all the way to magic-byte sniffing. We now require an allowlisted extension from the normalized filename on both the client and backend in supportAttachments.ts (line 70) and supportAttachments.ts (line 57), and added regression coverage in supportAttachments.test.ts (line 151) and supportAttachments.test.ts (line 8). I did not add signature checks, because 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. --- ROADMAP.md | 2 + .../hooks/useInboxMessageActions.test.ts | 49 +++++++++++++ .../app/inbox/hooks/useInboxMessageActions.ts | 22 ++++++ apps/web/src/app/inbox/page.tsx | 13 ++-- packages/convex/convex/supportAttachments.ts | 72 ++++++++++++++++--- .../convex/tests/supportAttachments.test.ts | 4 +- .../web-shared/src/supportAttachments.test.ts | 32 +++++++++ packages/web-shared/src/supportAttachments.ts | 14 ++-- 8 files changed, 182 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/app/inbox/hooks/useInboxMessageActions.test.ts create mode 100644 packages/web-shared/src/supportAttachments.test.ts 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/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 30ee012..9e624fc 100644 --- a/apps/web/src/app/inbox/hooks/useInboxMessageActions.ts +++ b/apps/web/src/app/inbox/hooks/useInboxMessageActions.ts @@ -2,6 +2,7 @@ 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"; @@ -10,6 +11,7 @@ export type ConversationUiPatch = { status?: ConversationStatus; lastMessageAt?: number; optimisticLastMessage?: string; + optimisticBaseMessageId?: Id<"messages"> | null; }; interface ConversationSummaryForActions { @@ -51,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; } @@ -69,6 +72,23 @@ 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, @@ -146,6 +166,7 @@ export function useInboxMessageActions({ unreadByAgent: 0, lastMessageAt: now, optimisticLastMessage: getOptimisticLastMessage(content), + optimisticBaseMessageId: context.latestMessageId ?? null, }); try { @@ -175,6 +196,7 @@ export function useInboxMessageActions({ } }, [ api, + context.latestMessageId, context.selectedConversationId, context.userId, getOptimisticLastMessage, diff --git a/apps/web/src/app/inbox/page.tsx b/apps/web/src/app/inbox/page.tsx index aee3082..b7ed144 100644 --- a/apps/web/src/app/inbox/page.tsx +++ b/apps/web/src/app/inbox/page.tsx @@ -24,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"; @@ -246,25 +247,20 @@ function InboxContent(): React.JSX.Element | null { 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.senderType !== "agent" || - latestMessage.createdAt < (patch.lastMessageAt ?? 0) - ) { + 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 && @@ -331,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}`), }, diff --git a/packages/convex/convex/supportAttachments.ts b/packages/convex/convex/supportAttachments.ts index 5bc8482..6a486de 100644 --- a/packages/convex/convex/supportAttachments.ts +++ b/packages/convex/convex/supportAttachments.ts @@ -12,7 +12,6 @@ import { MAX_SUPPORT_ATTACHMENT_BYTES, MAX_SUPPORT_ATTACHMENTS_PER_PARENT, STAGED_SUPPORT_ATTACHMENT_TTL_MS, - SUPPORTED_SUPPORT_ATTACHMENT_MIME_TYPES, SUPPORTED_SUPPORT_ATTACHMENT_TYPE_LABEL, } from "./supportAttachmentTypes"; import { createError } from "./utils/errors"; @@ -45,6 +44,18 @@ 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">[] @@ -61,6 +72,50 @@ function normalizeAttachmentFileName(fileName?: string): string { 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; } @@ -351,18 +406,19 @@ export const finalizeUpload = mutation({ 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 mimeType = metadata.contentType?.trim() ?? ""; - if (!SUPPORTED_SUPPORT_ATTACHMENT_MIME_TYPES.has(mimeType)) { + const fileValidation = validateSupportAttachmentFileName(normalizedFileName); + if (fileValidation.status === "rejected") { await deleteUploadedFileIfPresent(ctx, args.storageId); return { status: "rejected" as const, - message: `Unsupported file type. Allowed: ${SUPPORTED_SUPPORT_ATTACHMENT_TYPE_LABEL}.`, + message: fileValidation.message, }; } @@ -378,8 +434,8 @@ export const finalizeUpload = mutation({ const attachmentId = await ctx.db.insert("supportAttachments", { workspaceId: args.workspaceId, storageId: args.storageId, - fileName: normalizeAttachmentFileName(args.fileName), - mimeType, + fileName: normalizedFileName, + mimeType: fileValidation.mimeType, size: metadata.size, status: "staged", uploadedByType: actor.accessType, @@ -397,8 +453,8 @@ export const finalizeUpload = mutation({ return { status: "staged" as const, attachmentId, - fileName: normalizeAttachmentFileName(args.fileName), - mimeType, + fileName: normalizedFileName, + mimeType: fileValidation.mimeType, size: metadata.size, }; }, diff --git a/packages/convex/tests/supportAttachments.test.ts b/packages/convex/tests/supportAttachments.test.ts index 1580eec..6a5798e 100644 --- a/packages/convex/tests/supportAttachments.test.ts +++ b/packages/convex/tests/supportAttachments.test.ts @@ -148,13 +148,13 @@ describe("support attachments", () => { ]); }); - it("rejects unsupported uploads and removes the uploaded storage object", async () => { + 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": "application/octet-stream" }, + headers: { "Content-Type": "image/png" }, body: TEXT_FILE_BYTES, }); expect(response.ok).toBe(true); 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 index 533b09c..e356e54 100644 --- a/packages/web-shared/src/supportAttachments.ts +++ b/packages/web-shared/src/supportAttachments.ts @@ -41,9 +41,6 @@ const MIME_TYPE_BY_EXTENSION: Record = { zip: "application/zip", }; -const SUPPORTED_SUPPORT_ATTACHMENT_MIME_TYPES = new Set(Object.values(MIME_TYPE_BY_EXTENSION)); -SUPPORTED_SUPPORT_ATTACHMENT_MIME_TYPES.add("application/x-zip-compressed"); - export const SUPPORT_ATTACHMENT_ACCEPT = [ ".png", ".jpg", @@ -57,6 +54,11 @@ export const SUPPORT_ATTACHMENT_ACCEPT = [ ".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) { @@ -66,11 +68,7 @@ function inferMimeTypeFromFileName(fileName: string): string | null { } export function getSupportAttachmentMimeType(file: Pick): string | null { - const normalizedType = file.type?.trim().toLowerCase(); - if (normalizedType && SUPPORTED_SUPPORT_ATTACHMENT_MIME_TYPES.has(normalizedType)) { - return normalizedType; - } - return inferMimeTypeFromFileName(file.name); + return inferMimeTypeFromFileName(normalizeSupportAttachmentFileName(file.name)); } export function formatSupportAttachmentSize(size: number): string {