diff --git a/bun.lockb b/bun.lockb index 75dfddd..328f248 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/src/components/cases/documents-tab.tsx b/src/components/cases/documents-tab.tsx index d611cda..97e0025 100644 --- a/src/components/cases/documents-tab.tsx +++ b/src/components/cases/documents-tab.tsx @@ -17,6 +17,7 @@ import { Eye, ExternalLink, Pencil, + Share2, } from "lucide-react"; import { formatDate } from "@/lib/format"; import { toast } from "sonner"; @@ -26,6 +27,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { ShareDocumentDialog } from "./share-document-dialog"; const MAX_BYTES = 150 * 1024 * 1024; @@ -41,6 +43,7 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) { const [previewDoc, setPreviewDoc] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); + const [shareDoc, setShareDoc] = useState(null); const fileRef = useRef(null); const load = useCallback(async () => { @@ -425,6 +428,7 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) { + @@ -461,6 +465,13 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) { + + !o && setShareDoc(null)} + caseId={caseId} + doc={shareDoc} + /> ); } diff --git a/src/components/cases/share-document-dialog.tsx b/src/components/cases/share-document-dialog.tsx new file mode 100644 index 0000000..71bab34 --- /dev/null +++ b/src/components/cases/share-document-dialog.tsx @@ -0,0 +1,192 @@ +import { useEffect, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { supabase } from "@/integrations/supabase/client"; +import { toast } from "sonner"; +import { Loader2, Mail } from "lucide-react"; + +type Contact = { id: string; name: string; email: string | null; role: string | null }; + +export function ShareDocumentDialog({ + open, + onOpenChange, + caseId, + doc, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + caseId: string; + doc: { id: string; name: string; storage_path: string } | null; +}) { + const [contacts, setContacts] = useState([]); + const [selected, setSelected] = useState>({}); + const [extraEmail, setExtraEmail] = useState(""); + const [subject, setSubject] = useState(""); + const [message, setMessage] = useState(""); + const [loading, setLoading] = useState(false); + const [sending, setSending] = useState(false); + + useEffect(() => { + if (!open || !doc) return; + setSelected({}); + setExtraEmail(""); + setSubject(`Shared document: ${doc.name}`); + setMessage(`Please find the attached document: ${doc.name}.\n\nClick the link in the email to download. The link expires in 7 days.`); + (async () => { + setLoading(true); + const { data, error } = await supabase + .from("case_contacts") + .select("role, contact:contact_id(id, name, email)") + .eq("case_id", caseId); + setLoading(false); + if (error) { toast.error(error.message); return; } + const list: Contact[] = (data ?? []) + .map((row: any) => row.contact ? { ...row.contact, role: row.role } : null) + .filter(Boolean); + setContacts(list); + })(); + }, [open, caseId, doc]); + + const toggle = (id: string) => setSelected((s) => ({ ...s, [id]: !s[id] })); + + const send = async () => { + if (!doc) return; + const recipients = contacts.filter((c) => selected[c.id] && c.email).map((c) => c.email!); + const extras = extraEmail.split(/[,;\s]+/).map((s) => s.trim()).filter((s) => s.includes("@")); + const allRecipients = Array.from(new Set([...recipients, ...extras])); + if (allRecipients.length === 0) { toast.error("Pick at least one contact with an email, or enter an address"); return; } + + setSending(true); + // 7 days = 604800 seconds (max for signed URLs) + const { data: signed, error: signErr } = await supabase.storage + .from("case-documents") + .createSignedUrl(doc.storage_path, 60 * 60 * 24 * 7); + if (signErr || !signed) { + setSending(false); + toast.error(signErr?.message || "Could not create download link"); + return; + } + + const safeMsg = (message || "").replace(/&/g, "&").replace(//g, ">").replace(/\n/g, "
"); + const html = ` +
+

${safeMsg}

+

+ + Download ${doc.name} + +

+

+ Or copy this link into your browser:
+ ${signed.signedUrl} +

+

This download link expires in 7 days.

+
+ `; + + const { error } = await supabase.functions.invoke("send-smtp-email", { + body: { + to: allRecipients, + subject: subject || `Shared document: ${doc.name}`, + html, + context: "document_share", + case_id: caseId, + }, + }); + setSending(false); + if (error) { toast.error(error.message); return; } + toast.success(`Document shared with ${allRecipients.length} recipient${allRecipients.length > 1 ? "s" : ""}`); + onOpenChange(false); + }; + + return ( + + + + Share document + +
+
+ {doc?.name} +
+ +
+ +
+ {loading ? ( +
+ Loading contacts… +
+ ) : contacts.length === 0 ? ( +
No contacts linked to this case.
+ ) : ( + contacts.map((c) => { + const hasEmail = !!c.email; + return ( + + ); + }) + )} +
+
+ +
+ + setExtraEmail(e.target.value)} + placeholder="comma-separated, optional" + className="mt-1" + /> +
+ +
+ + setSubject(e.target.value)} + className="mt-1" + /> +
+ +
+ +