From 34312b62bccf9d978f3cdb1d20ab85852fc23319 Mon Sep 17 00:00:00 2001 From: Gitead Date: Tue, 14 Jul 2026 00:36:08 -0400 Subject: [PATCH] feat: add "Choose from Server" attachment picker to claim/preauth forms and chatbot Native file inputs can't be pointed at a default server folder for security reasons, so instead the Browse buttons on the Claim/PreAuth/Select Procedures forms and the AI chatbot's attach button now also offer picking from previously saved Cloud Storage attachments (e.g. Copy Agent screenshots) via a new server-side picker modal, scoped per-patient on the claim forms and unscoped (recent across all patients) in the chatbot. Co-Authored-By: Claude Sonnet 5 --- apps/Backend/src/routes/claims.ts | 43 ++++ .../src/storage/cloudStorage-storage.ts | 20 ++ .../src/components/claims/claim-form.tsx | 2 + .../file-upload/multiple-file-upload-zone.tsx | 51 ++++- .../server-attachments-picker-modal.tsx | 207 ++++++++++++++++++ .../src/components/layout/chatbot.tsx | 20 ++ 6 files changed, 332 insertions(+), 11 deletions(-) create mode 100644 apps/Frontend/src/components/file-upload/server-attachments-picker-modal.tsx diff --git a/apps/Backend/src/routes/claims.ts b/apps/Backend/src/routes/claims.ts index 1fdae023..d552674a 100755 --- a/apps/Backend/src/routes/claims.ts +++ b/apps/Backend/src/routes/claims.ts @@ -158,6 +158,49 @@ router.post( } ); +// GET /api/claims/patient-attachments?patientId=123 +// Lists this patient's previously saved Cloud Storage "Attachments" (e.g. Copy Agent screenshots) +// so they can be picked and re-attached to a claim/preauth without re-uploading from disk. +router.get( + "/patient-attachments", + async (req: Request, res: Response): Promise => { + if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); + + const patientId = Number(req.query.patientId); + if (!patientId || isNaN(patientId)) { + return res.status(400).json({ error: "Invalid patientId" }); + } + + try { + const files = await storage.listPatientCategoryCloudFiles(patientId, "Attachments"); + return res.json({ error: false, data: files }); + } catch (err: any) { + console.error("[patient-attachments]", err); + return res.status(500).json({ error: "Failed to list attachments", message: err?.message }); + } + } +); + +// GET /api/claims/recent-attachments?limit=20 +// Lists recently saved Cloud Storage "Attachments" across all patients (e.g. Copy Agent +// screenshots), for use in contexts (like the AI chatbot) that don't have a patient in scope yet. +router.get( + "/recent-attachments", + async (req: Request, res: Response): Promise => { + if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); + + const limit = Number(req.query.limit) || 20; + + try { + const files = await storage.listRecentAttachmentFiles(limit); + return res.json({ error: false, data: files }); + } catch (err: any) { + console.error("[recent-attachments]", err); + return res.status(500).json({ error: "Failed to list attachments", message: err?.message }); + } + } +); + router.post( "/mh-provider-login", async (req: Request, res: Response): Promise => { diff --git a/apps/Backend/src/storage/cloudStorage-storage.ts b/apps/Backend/src/storage/cloudStorage-storage.ts index fc480fea..378bfe1f 100755 --- a/apps/Backend/src/storage/cloudStorage-storage.ts +++ b/apps/Backend/src/storage/cloudStorage-storage.ts @@ -161,6 +161,7 @@ export interface IStorage { patientId: number, category: string ): Promise; + listRecentAttachmentFiles(limit?: number): Promise; } /* ------------------------------- Implementation ------------------------------- */ @@ -663,6 +664,25 @@ export const cloudStorageStorage: IStorage = { return files.map(serializeFile) as unknown as CloudFile[]; }, + async listRecentAttachmentFiles(limit: number = 20) { + const files = await db.cloudFile.findMany({ + where: { folder: { name: "Attachments" }, isComplete: true }, + orderBy: { createdAt: "desc" }, + take: limit, + select: { + id: true, + name: true, + mimeType: true, + fileSize: true, + folderId: true, + isComplete: true, + createdAt: true, + updatedAt: true, + }, + }); + return files.map(serializeFile) as unknown as CloudFile[]; + }, + // --- STREAM --- async streamFileTo(resStream: NodeJS.WritableStream, fileId: number) { const file = await db.cloudFile.findUnique({ diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index 1b00bb44..90b8ab9b 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -2320,6 +2320,7 @@ export function ClaimForm({ isUploading={isUploading} acceptedFileTypes="application/pdf,image/jpeg,image/jpg,image/png,image/webp" maxFiles={10} + patientId={patientId} /> {form.uploadedFiles.length > 0 && (
    @@ -2883,6 +2884,7 @@ export function ClaimForm({ isUploading={isUploading} acceptedFileTypes="application/pdf,image/jpeg,image/jpg,image/png,image/webp" maxFiles={10} + patientId={patientId} /> {form.uploadedFiles.length > 0 && (
      diff --git a/apps/Frontend/src/components/file-upload/multiple-file-upload-zone.tsx b/apps/Frontend/src/components/file-upload/multiple-file-upload-zone.tsx index ef7ec5a1..eb289a78 100755 --- a/apps/Frontend/src/components/file-upload/multiple-file-upload-zone.tsx +++ b/apps/Frontend/src/components/file-upload/multiple-file-upload-zone.tsx @@ -9,6 +9,7 @@ import { Upload, X, FilePlus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useToast } from "@/hooks/use-toast"; import { cn } from "@/lib/utils"; +import { ServerAttachmentsPickerModal } from "@/components/file-upload/server-attachments-picker-modal"; export type MultipleFileUploadZoneHandle = { getFiles: () => File[]; @@ -25,6 +26,9 @@ interface FileUploadZoneProps { maxFileSizeMB?: number; //OPTIONAL: per-mime (or wildcard) map in MB: { "application/pdf": 10, "image/*": 2 } maxFileSizeByType?: Record; + // OPTIONAL: when set, shows a "Choose from Server" option listing this patient's + // previously saved Cloud Storage attachments (e.g. Copy Agent screenshots) + patientId?: number; } export const MultipleFileUploadZone = forwardRef< @@ -39,12 +43,14 @@ export const MultipleFileUploadZone = forwardRef< maxFiles = 10, maxFileSizeMB = 10, // default fallback per-file size (MB) maxFileSizeByType, // optional per-type overrides, e.g. { "application/pdf": 10, "image/*": 2 } + patientId, }, ref ) => { const { toast } = useToast(); const [isDragging, setIsDragging] = useState(false); const [uploadedFiles, setUploadedFiles] = useState([]); + const [isServerPickerOpen, setIsServerPickerOpen] = useState(false); const fileInputRef = useRef(null); const parsedAccept = acceptedFileTypes @@ -222,7 +228,7 @@ export const MultipleFileUploadZone = forwardRef< [onFilesChange] ); - const handleFiles = (files: FileList | null) => { + const handleFiles = (files: FileList | File[] | null) => { if (!files) return; const newFiles = Array.from(files).filter(validateFile); @@ -439,19 +445,42 @@ export const MultipleFileUploadZone = forwardRef< Or click to browse files

      - +
      + + {patientId ? ( + + ) : null} +
      )} + + {patientId ? ( + setIsServerPickerOpen(false)} + patientId={patientId} + onFilesSelected={(files) => handleFiles(files)} + /> + ) : null} ); } diff --git a/apps/Frontend/src/components/file-upload/server-attachments-picker-modal.tsx b/apps/Frontend/src/components/file-upload/server-attachments-picker-modal.tsx new file mode 100644 index 00000000..e2f87c60 --- /dev/null +++ b/apps/Frontend/src/components/file-upload/server-attachments-picker-modal.tsx @@ -0,0 +1,207 @@ +import React, { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { FileText, File as FileIcon, Image as ImageIcon, Loader2, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { apiRequest } from "@/lib/queryClient"; +import { useToast } from "@/hooks/use-toast"; +import type { CloudFile } from "@repo/db/types"; + +interface ServerAttachmentsPickerModalProps { + open: boolean; + onClose: () => void; + // When set, lists only this patient's saved attachments. When omitted, lists + // recently saved attachments across all patients (used where no patient is in scope yet). + patientId?: number; + onFilesSelected: (files: File[]) => void; +} + +function fileIcon(mime: string | undefined | null) { + if (mime?.startsWith("image/")) return ImageIcon; + if (mime === "application/pdf") return FileText; + return FileIcon; +} + +function AttachmentThumbnail({ file }: { file: CloudFile }) { + const [src, setSrc] = useState(null); + + useEffect(() => { + if (!file.mimeType?.startsWith("image/")) return; + let objectUrl: string | null = null; + let cancelled = false; + + (async () => { + const res = await apiRequest("GET", `/api/cloud-storage/files/${file.id}/content`); + if (!res.ok || cancelled) return; + const blob = await res.blob(); + objectUrl = URL.createObjectURL(blob); + if (!cancelled) setSrc(objectUrl); + })(); + + return () => { + cancelled = true; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [file.id, file.mimeType]); + + if (file.mimeType?.startsWith("image/")) { + return src ? ( + {file.name} + ) : ( + + ); + } + + const Icon = fileIcon(file.mimeType); + return ; +} + +export function ServerAttachmentsPickerModal({ + open, + onClose, + patientId, + onFilesSelected, +}: ServerAttachmentsPickerModalProps) { + const { toast } = useToast(); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isAdding, setIsAdding] = useState(false); + + const endpoint = patientId + ? `/api/claims/patient-attachments?patientId=${patientId}` + : `/api/claims/recent-attachments?limit=40`; + + const { data, isLoading, isError } = useQuery({ + queryKey: ["/api/claims/attachments-picker", patientId ?? "recent"], + queryFn: async () => { + const res = await apiRequest("GET", endpoint); + const json = await res.json(); + if (!res.ok || json.error) { + throw new Error(json.message || "Failed to load attachments"); + } + return (json.data ?? []) as CloudFile[]; + }, + enabled: open, + }); + + useEffect(() => { + if (!open) setSelectedIds(new Set()); + }, [open]); + + if (!open) return null; + + const files = data ?? []; + + const toggleSelected = (id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleAddSelected = async () => { + if (selectedIds.size === 0) return; + setIsAdding(true); + try { + const selectedFiles = files.filter((f) => selectedIds.has(f.id as number)); + const built = await Promise.all( + selectedFiles.map(async (f) => { + const res = await apiRequest("GET", `/api/cloud-storage/files/${f.id}/content`); + const blob = await res.blob(); + return new File([blob], f.name, { type: f.mimeType || blob.type }); + }) + ); + onFilesSelected(built); + onClose(); + } catch (err) { + toast({ + title: "Failed to add files", + description: "Could not fetch one or more attachments from the server.", + variant: "destructive", + }); + } finally { + setIsAdding(false); + } + }; + + return ( +
      +
      { + if (!isAdding) onClose(); + }} + /> + +
      +
      +

      Choose from Server Attachments

      +

      + {patientId + ? "Previously saved screenshots and files for this patient." + : "Recently saved screenshots and files across all patients."} +

      +
      + +
      + {isLoading ? ( +
      + +
      + ) : isError ? ( +

      + Failed to load attachments. +

      + ) : files.length === 0 ? ( +

      + {patientId + ? "No screenshots saved yet for this patient." + : "No screenshots saved yet."} +

      + ) : ( +
      + {files.map((file) => { + const isSelected = selectedIds.has(file.id as number); + return ( + + ); + })} +
      + )} +
      + +
      + + +
      +
      +
      + ); +} diff --git a/apps/Frontend/src/components/layout/chatbot.tsx b/apps/Frontend/src/components/layout/chatbot.tsx index 281fffe8..5ded4b28 100644 --- a/apps/Frontend/src/components/layout/chatbot.tsx +++ b/apps/Frontend/src/components/layout/chatbot.tsx @@ -11,6 +11,7 @@ import { Loader2, RotateCcw, Paperclip, + Image as ImageIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -18,6 +19,7 @@ import { useLocation } from "wouter"; import { cn } from "@/lib/utils"; import { apiRequest } from "@/lib/queryClient"; import { setChatbotPendingFiles } from "@/lib/chatbotFileStore"; +import { ServerAttachmentsPickerModal } from "@/components/file-upload/server-attachments-picker-modal"; type Step = | "menu" @@ -204,6 +206,7 @@ export function ChatbotButton() { renderingProvider: string | null; } | null>(null); const [pendingFiles, setPendingFiles] = useState([]); + const [isServerPickerOpen, setIsServerPickerOpen] = useState(false); const [, setLocation] = useLocation(); const messagesEndRef = useRef(null); const pasteRef = useRef(null); @@ -1490,6 +1493,16 @@ export function ChatbotButton() { > +
      )}