Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-24 03:35:05 +00:00
co-authored by renee-png
parent f905abb05b
commit c2ebdf2fe7
2 changed files with 366 additions and 0 deletions
+313
View File
@@ -0,0 +1,313 @@
// Messages tab on the case detail page. Lists incoming emails attached to
// this case, with body preview and "save attachment to case" action.
import { useEffect, useState, useCallback } from "react";
import { supabase } from "@/integrations/supabase/client";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Loader2, Mail, Paperclip, Save, ExternalLink } from "lucide-react";
import { toast } from "sonner";
import { formatDate } from "@/lib/format";
interface AttachmentMeta {
filename: string;
mime_type: string;
size_bytes: number;
storage_path: string;
}
interface MessageRow {
id: string;
received_at: string;
from_address: string | null;
from_name: string | null;
to_addresses: string[];
cc_addresses: string[];
subject: string | null;
body_text: string | null;
body_html: string | null;
match_status: string;
match_reason: string | null;
attachments: AttachmentMeta[];
}
export function CaseMessagesTab({ caseId }: { caseId: string }) {
const [emails, setEmails] = useState<MessageRow[]>([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [savingPath, setSavingPath] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
const { data, error } = await supabase
.from("incoming_emails")
.select(
"id, received_at, from_address, from_name, to_addresses, cc_addresses, subject, body_text, body_html, match_status, match_reason, attachments",
)
.eq("case_id", caseId)
.order("received_at", { ascending: false });
if (error) {
toast.error("Failed to load messages", { description: error.message });
} else {
const rows = ((data ?? []) as any[]).map((r) => ({
...r,
attachments: Array.isArray(r.attachments) ? r.attachments : [],
})) as MessageRow[];
setEmails(rows);
if (rows.length > 0 && !selectedId) setSelectedId(rows[0].id);
}
setLoading(false);
}, [caseId, selectedId]);
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [caseId]);
const detach = async (id: string) => {
if (!confirm("Detach this email from the case? It will go back to the inbox.")) return;
const { error } = await supabase
.from("incoming_emails")
.update({ case_id: null, match_status: "unmatched", matched_at: null })
.eq("id", id);
if (error) {
toast.error("Could not detach", { description: error.message });
} else {
toast.success("Detached from case");
setEmails((prev) => prev.filter((e) => e.id !== id));
if (selectedId === id) setSelectedId(null);
}
};
const saveAttachment = async (att: AttachmentMeta) => {
setSavingPath(att.storage_path);
try {
// Download the bytes from the email-attachments bucket
const { data: blob, error: dlErr } = await supabase.storage
.from("email-attachments")
.download(att.storage_path);
if (dlErr || !blob) {
toast.error("Could not load attachment", { description: dlErr?.message });
return;
}
// Upload into case-documents under this case
const targetPath = `${caseId}/email/${Date.now()}-${att.filename.replace(/[^\w.\-]+/g, "_")}`;
const { error: upErr } = await supabase.storage
.from("case-documents")
.upload(targetPath, blob, { contentType: att.mime_type, upsert: false });
if (upErr) {
toast.error("Upload failed", { description: upErr.message });
return;
}
const { error: insErr } = await supabase.from("documents").insert({
case_id: caseId,
name: att.filename,
storage_path: targetPath,
mime_type: att.mime_type,
size_bytes: att.size_bytes,
folder: "email",
description: "Saved from email",
});
if (insErr) {
toast.error("Saved file but couldn't index it", { description: insErr.message });
return;
}
toast.success(`Saved ${att.filename} to Documents`);
} finally {
setSavingPath(null);
}
};
const openAttachment = async (att: AttachmentMeta) => {
const { data, error } = await supabase.storage
.from("email-attachments")
.createSignedUrl(att.storage_path, 60 * 5);
if (error || !data) {
toast.error("Could not open", { description: error?.message });
return;
}
window.open(data.signedUrl, "_blank");
};
if (loading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (emails.length === 0) {
return (
<Card className="border-dashed">
<CardContent className="py-10 text-center text-sm text-muted-foreground">
<Mail className="h-8 w-8 mx-auto mb-3 opacity-50" />
No emails attached to this case yet. Incoming emails matching this
case's contacts, case number, or thread will appear here automatically.
</CardContent>
</Card>
);
}
const selected = emails.find((e) => e.id === selectedId) ?? null;
return (
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
<Card className="border-border/60 overflow-hidden">
<div className="divide-y max-h-[70vh] overflow-y-auto">
{emails.map((e) => (
<button
key={e.id}
type="button"
onClick={() => setSelectedId(e.id)}
className={`w-full text-left px-3 py-3 hover:bg-muted/50 transition-colors ${
selectedId === e.id ? "bg-muted" : ""
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm">
{e.from_name || e.from_address || "Unknown sender"}
</span>
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{formatDate(e.received_at)}
</span>
</div>
<div className="text-sm truncate mt-0.5">
{e.subject || "(no subject)"}
</div>
<div className="flex items-center gap-1.5 mt-1">
{e.attachments.length > 0 && (
<Badge variant="secondary" className="h-4 text-[9px] px-1.5">
<Paperclip className="h-2.5 w-2.5 mr-0.5" />
{e.attachments.length}
</Badge>
)}
{e.match_status === "manual" && (
<Badge variant="outline" className="h-4 text-[9px] px-1.5">
Manual
</Badge>
)}
</div>
</button>
))}
</div>
</Card>
<Card className="border-border/60">
{selected ? (
<CardContent className="p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h2 className="font-serif text-lg leading-tight">
{selected.subject || "(no subject)"}
</h2>
<div className="text-sm text-muted-foreground mt-1">
From:{" "}
<span className="text-foreground">
{selected.from_name
? `${selected.from_name} <${selected.from_address}>`
: selected.from_address}
</span>
</div>
<div className="text-xs text-muted-foreground">
To: {selected.to_addresses.join(", ")}
</div>
{selected.cc_addresses.length > 0 && (
<div className="text-xs text-muted-foreground">
Cc: {selected.cc_addresses.join(", ")}
</div>
)}
<div className="text-xs text-muted-foreground">
{new Date(selected.received_at).toLocaleString()}
</div>
{selected.match_reason && (
<div className="text-xs text-muted-foreground italic mt-1">
Matched: {selected.match_reason}
</div>
)}
</div>
<Button
size="sm"
variant="outline"
onClick={() => detach(selected.id)}
>
Detach
</Button>
</div>
{selected.attachments.length > 0 && (
<div className="border rounded-md p-2 space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
Attachments
</div>
{selected.attachments.map((att) => (
<div
key={att.storage_path}
className="flex items-center justify-between gap-2 text-sm"
>
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{att.filename}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatBytes(att.size_bytes)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="ghost"
onClick={() => openAttachment(att)}
title="Open"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={savingPath === att.storage_path}
onClick={() => saveAttachment(att)}
title="Save to case Documents"
>
{savingPath === att.storage_path ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
))}
</div>
)}
<div className="border-t pt-3">
{selected.body_html ? (
<iframe
title="Email body"
srcDoc={selected.body_html}
sandbox=""
className="w-full min-h-[400px] border rounded bg-white"
/>
) : (
<pre className="text-sm whitespace-pre-wrap font-sans">
{selected.body_text || "(empty)"}
</pre>
)}
</div>
</CardContent>
) : (
<CardContent className="py-16 text-center text-sm text-muted-foreground">
Select a message to read.
</CardContent>
)}
</Card>
</div>
);
}
function formatBytes(b: number): string {
if (!b) return "—";
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
}
+53
View File
@@ -0,0 +1,53 @@
// Re-runs case matching against incoming emails.
// POST { ids?: string[] } -> rematch the listed email ids
// POST {} -> rematch all currently unmatched, non-archived emails
import { createFileRoute } from "@tanstack/react-router";
import { supabaseAdmin } from "@/integrations/supabase/client.server";
import { matchAndUpdateEmails } from "@/lib/email-matching.server";
export const Route = createFileRoute("/hooks/rematch-emails")({
server: {
handlers: {
POST: async ({ request }) => {
let ids: string[] = [];
try {
const body = (await request.json()) as { ids?: string[] };
if (Array.isArray(body?.ids)) ids = body.ids.filter((x) => typeof x === "string");
} catch {
/* no body is fine */
}
if (ids.length === 0) {
const { data, error } = await supabaseAdmin
.from("incoming_emails")
.select("id")
.is("case_id", null)
.eq("is_archived", false)
.order("received_at", { ascending: false })
.limit(500);
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
ids = (data ?? []).map((r) => r.id);
}
try {
const stats = await matchAndUpdateEmails(ids);
return new Response(
JSON.stringify({ ok: true, scanned: ids.length, ...stats }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return new Response(JSON.stringify({ error: msg }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
},
});