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 ? (
+
+ ) : (
+
+ );
+ }
+
+ 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() {
>
+
+ setIsServerPickerOpen(false)}
+ onFilesSelected={(files) =>
+ setPendingFiles((prev) => [...prev, ...files].slice(0, 5))
+ }
+ />
)}