From 7c91b625a63b2f13d1203c703b098e91621a8ee0 Mon Sep 17 00:00:00 2001 From: Francisco Pessano Date: Sun, 8 Jun 2025 01:49:19 -0300 Subject: [PATCH] feat: Add manual response dialog and update debt status handling - Introduced ManualResponseDialog component for user-initiated responses when AI analysis is unclear. - Updated DebtTimeline to include AlertTriangle icon for debts requiring manual review. - Enhanced supabase functions to handle new debt status 'requires_manual_review' and message type 'manual_response'. - Implemented email variable processing utilities to support dynamic email content generation. - Created tests for email variable extraction and replacement functions. - Updated database schema to accommodate new statuses and message types, including relevant constraints and indexes. - Adjusted negotiation and email sending logic to ensure proper handling of manual responses and variable replacements. --- src/components/ConversationTimeline.tsx | 7 +- src/components/Dashboard.tsx | 3 +- src/components/DebtCard.tsx | 173 ++------- src/components/DebtTimeline.tsx | 3 +- src/components/ManualResponseDialog.tsx | 341 ++++++++++++++++++ src/lib/emailVariables.test.ts | 124 +++++++ src/lib/emailVariables.ts | 245 +++++++++++++ src/lib/supabase.ts | 4 +- src/pages/api/postmark.ts | 2 +- supabase/functions/analyze-response/index.ts | 12 +- supabase/functions/negotiate/index.ts | 141 ++++++-- supabase/functions/send-email/index.ts | 126 ++++++- ...0250608050000_add_manual_review_status.sql | 49 +++ 13 files changed, 1059 insertions(+), 171 deletions(-) create mode 100644 src/components/ManualResponseDialog.tsx create mode 100644 src/lib/emailVariables.test.ts create mode 100644 src/lib/emailVariables.ts create mode 100644 supabase/migrations/20250608050000_add_manual_review_status.sql diff --git a/src/components/ConversationTimeline.tsx b/src/components/ConversationTimeline.tsx index da2341c..f5a6bcd 100644 --- a/src/components/ConversationTimeline.tsx +++ b/src/components/ConversationTimeline.tsx @@ -48,6 +48,7 @@ const messageTypeLabels = { counter_offer: "Counter Offer", acceptance: "Offer Accepted", rejection: "Offer Rejected", + manual_response: "Manual Response", }; const statusColors = { @@ -57,6 +58,7 @@ const statusColors = { sent: "text-orange-600 dark:text-orange-400", awaiting_response: "text-blue-600 dark:text-blue-400", counter_negotiating: "text-yellow-600 dark:text-yellow-400", + requires_manual_review: "text-amber-600 dark:text-amber-400", accepted: "text-green-600 dark:text-green-400", rejected: "text-red-600 dark:text-red-400", settled: "text-green-600 dark:text-green-400", @@ -71,6 +73,7 @@ const statusLabels = { sent: "Sent", awaiting_response: "Awaiting Response", counter_negotiating: "Counter Negotiating", + requires_manual_review: "Manual Review Required", accepted: "Accepted", rejected: "Rejected", settled: "Settled", @@ -384,7 +387,9 @@ export function ConversationTimeline({ ?.proposedAmount && (
Proposed Amount: $ - {message.ai_analysis.extractedTerms.proposedAmount} + {formatCurrency( + message.ai_analysis.extractedTerms.proposedAmount + )}
)} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 93210de..3cad4e9 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -162,6 +162,7 @@ export function Dashboard() { "approved", "awaiting_response", "counter_negotiating", + "requires_manual_review", ].includes(debt.status) ), settled: debts.filter((debt) => @@ -309,7 +310,7 @@ export function Dashboard() { ) : ( -
+
{debtList.map((debt) => (
diff --git a/src/components/DebtCard.tsx b/src/components/DebtCard.tsx index 7a8f7bc..03b4f48 100644 --- a/src/components/DebtCard.tsx +++ b/src/components/DebtCard.tsx @@ -47,6 +47,13 @@ import { import { supabase, type Debt, type DebtVariable } from "../lib/supabase"; import { toast } from "sonner"; import { formatCurrency } from "../lib/utils"; +import { + replaceVariables, + saveVariablesToDatabase, + getVariablesForTemplate, + updateVariablesForTextChange, +} from "../lib/emailVariables"; +import { ManualResponseDialog } from "./ManualResponseDialog"; interface DebtCardProps { debt: Debt; @@ -65,6 +72,8 @@ const statusColors = { "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800", counter_negotiating: "bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-800", + requires_manual_review: + "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800", accepted: "bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800", rejected: @@ -84,6 +93,7 @@ const statusLabels = { sent: "Sent", awaiting_response: "Awaiting Response", counter_negotiating: "Counter Negotiating", + requires_manual_review: "Manual Review Required", accepted: "Accepted", rejected: "Rejected", settled: "Settled", @@ -119,35 +129,6 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) { }); }; - // Extract variables from text in {{ variable }} format - const extractVariables = (text: string): string[] => { - const variableRegex = /\{\{\s*([^}]+)\s*\}\}/g; - const matches: string[] = []; - let match; - while ((match = variableRegex.exec(text)) !== null) { - if (!matches.includes(match[1].trim())) { - matches.push(match[1].trim()); - } - } - return matches; - }; - - // Replace variables in text - const replaceVariables = ( - text: string, - variables: Record - ): string => { - let result = text; - Object.entries(variables).forEach(([key, value]) => { - const regex = new RegExp( - `\\{\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}\\}`, - "g" - ); - result = result.replace(regex, value); - }); - return result; - }; - const EditableResponseDialog = () => { const [isEditing, setIsEditing] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -157,56 +138,6 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) { // Check if debt is in read-only state (approved or sent) - // Load variables from database - const loadVariables = async () => { - try { - const { data: dbVariables, error } = await supabase - .from("debt_variables") - .select("variable_name, variable_value") - .eq("debt_id", debt.id); - - if (error) throw error; - - const loadedVariables: Record = {}; - dbVariables?.forEach((dbVar) => { - loadedVariables[dbVar.variable_name] = dbVar.variable_value || ""; - }); - - return loadedVariables; - } catch (error) { - console.error("Error loading variables:", error); - return {}; - } - }; - - // Save variables to database - const saveVariables = async (variablesToSave: Record) => { - try { - // First, delete existing variables for this debt - await supabase.from("debt_variables").delete().eq("debt_id", debt.id); - - // Then insert new variables - const variableRecords = Object.entries(variablesToSave).map( - ([name, value]) => ({ - debt_id: debt.id, - variable_name: name, - variable_value: value, - }) - ); - - if (variableRecords.length > 0) { - const { error } = await supabase - .from("debt_variables") - .insert(variableRecords); - - if (error) throw error; - } - } catch (error) { - console.error("Error saving variables:", error); - throw error; - } - }; - // Initialize data when dialog opens useEffect(() => { const initializeData = async () => { @@ -215,18 +146,12 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) { setSubject(aiEmail.subject || ""); setBody(aiEmail.body || ""); - // Extract variables from both subject and body - const allText = `${aiEmail.subject || ""} ${aiEmail.body || ""}`; - const extractedVars = extractVariables(allText); - - // Load saved variables from database - const savedVariables = await loadVariables(); - - // Merge extracted variables with saved values - const initialVariables: Record = {}; - extractedVars.forEach((variable) => { - initialVariables[variable] = savedVariables[variable] || ""; - }); + // Get variables for the template using the modular function + const initialVariables = await getVariablesForTemplate( + debt.id, + aiEmail.subject || "", + aiEmail.body || "" + ); setVariables(initialVariables); } @@ -238,54 +163,24 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) { // Update variables when body changes const handleBodyChange = (newBody: string) => { setBody(newBody); - // Extract variables from the new body text - const newVariables = extractVariables(newBody); - const updatedVariables = { ...variables }; - - // Add new variables if they don't exist - newVariables.forEach((variable) => { - if (!(variable in updatedVariables)) { - updatedVariables[variable] = ""; - } - }); - - // Remove variables that no longer exist in the text - Object.keys(updatedVariables).forEach((variable) => { - if ( - !newVariables.includes(variable) && - !extractVariables(subject).includes(variable) - ) { - delete updatedVariables[variable]; - } - }); - + // Update variables using the modular function + const updatedVariables = updateVariablesForTextChange( + variables, + newBody, + subject + ); setVariables(updatedVariables); }; // Update variables when subject changes const handleSubjectChange = (newSubject: string) => { setSubject(newSubject); - // Extract variables from the new subject text - const newVariables = extractVariables(newSubject); - const updatedVariables = { ...variables }; - - // Add new variables if they don't exist - newVariables.forEach((variable) => { - if (!(variable in updatedVariables)) { - updatedVariables[variable] = ""; - } - }); - - // Remove variables that no longer exist in the text - Object.keys(updatedVariables).forEach((variable) => { - if ( - !newVariables.includes(variable) && - !extractVariables(body).includes(variable) - ) { - delete updatedVariables[variable]; - } - }); - + // Update variables using the modular function + const updatedVariables = updateVariablesForTextChange( + variables, + newSubject, + body + ); setVariables(updatedVariables); }; @@ -295,11 +190,6 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) { setVariables(newVariables); }; - // Get preview text with variables replaced - const getPreviewText = () => { - return replaceVariables(`Subject: ${subject}\n\n${body}`, variables); - }; - // Get display text for subject (for preview in input) const getSubjectDisplay = () => { return replaceVariables(subject, variables); @@ -338,7 +228,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) { } // Save variables to database - await saveVariables(variables); + await saveVariablesToDatabase(debt.id, variables); toast.success("Changes saved", { description: @@ -722,6 +612,11 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) { {debt.metadata?.aiEmail && }
+ {/* Manual Response Dialog - show when requires manual review */} + {debt.status === "requires_manual_review" && ( + + )} + {/* Approve/Reject Buttons */} {showApproveRejectButtons() && (
diff --git a/src/components/DebtTimeline.tsx b/src/components/DebtTimeline.tsx index 852646a..f917424 100644 --- a/src/components/DebtTimeline.tsx +++ b/src/components/DebtTimeline.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { CheckCircle, Clock, - AlertCircle, + AlertTriangle, XCircle, StopCircle, Send, @@ -76,6 +76,7 @@ const statusIcons = { sent: Send, awaiting_response: Clock, counter_negotiating: RefreshCw, + requires_manual_review: AlertTriangle, accepted: ThumbsUp, rejected: ThumbsDown, settled: CheckCircle, diff --git a/src/components/ManualResponseDialog.tsx b/src/components/ManualResponseDialog.tsx new file mode 100644 index 0000000..7806bba --- /dev/null +++ b/src/components/ManualResponseDialog.tsx @@ -0,0 +1,341 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./ui/dialog"; +import { Button } from "./ui/button"; +import { Textarea } from "./ui/textarea"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { Badge } from "./ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; +import { Separator } from "./ui/separator"; +import { + AlertCircle, + Mail, + Send, + Eye, + MessageSquare, + Building2, + User, + Calendar, +} from "lucide-react"; +import { supabase, type Debt } from "../lib/supabase"; +import { toast } from "sonner"; + +interface ConversationMessage { + id: string; + debt_id: string; + message_type: string; + direction: "inbound" | "outbound"; + subject?: string; + body: string; + from_email?: string; + to_email?: string; + ai_analysis?: any; + created_at: string; +} + +interface ManualResponseDialogProps { + debt: Debt; + onResponseSent?: () => void; +} + +export function ManualResponseDialog({ + debt, + onResponseSent, +}: ManualResponseDialogProps) { + const [open, setOpen] = useState(false); + const [subject, setSubject] = useState(""); + const [body, setBody] = useState(""); + const [isSending, setIsSending] = useState(false); + const [lastMessage, setLastMessage] = useState( + null + ); + + // Fetch the last inbound message when dialog opens + useEffect(() => { + if (open) { + fetchLastInboundMessage(); + generateDefaultResponse(); + } + }, [open, debt.id]); + + const fetchLastInboundMessage = async () => { + try { + const { data, error } = await supabase + .from("conversation_messages") + .select("*") + .eq("debt_id", debt.id) + .eq("direction", "inbound") + .order("created_at", { ascending: false }) + .limit(1) + .single(); + + if (error) { + console.error("Error fetching last message:", error); + return; + } + + setLastMessage(data); + } catch (error) { + console.error("Error fetching last message:", error); + } + }; + + const generateDefaultResponse = () => { + // Generate a default subject line + const defaultSubject = `Re: ${ + debt.metadata?.subject || `Account ${debt.vendor}` + }`; + setSubject(defaultSubject); + + // Generate a basic template response + const defaultBody = `Dear ${debt.vendor}, + +Thank you for your recent correspondence regarding this account. + +I am writing to discuss the terms of this debt and explore options for resolution. I would like to work with you to find a mutually acceptable solution. + +Please let me know what options are available for resolving this matter. + +Thank you for your time and consideration. + +Sincerely, +{{ Your Name }}`; + + setBody(defaultBody); + }; + + const handleSendResponse = async () => { + if (!subject.trim() || !body.trim()) { + toast.error("Please fill in both subject and message"); + return; + } + + setIsSending(true); + try { + // Create conversation message + const { error: messageError } = await supabase + .from("conversation_messages") + .insert({ + debt_id: debt.id, + message_type: "manual_response", + direction: "outbound", + subject: subject.trim(), + body: body.trim(), + from_email: debt.metadata?.toEmail || "user@example.com", + to_email: debt.metadata?.fromEmail || debt.vendor, + message_id: `manual-${Date.now()}`, + }); + + if (messageError) { + throw messageError; + } + + // Update debt status + const { error: debtError } = await supabase + .from("debts") + .update({ + status: "awaiting_response", + last_message_at: new Date().toISOString(), + conversation_count: (debt.conversation_count || 0) + 1, + }) + .eq("id", debt.id); + + if (debtError) { + throw debtError; + } + + // Log the action + await supabase.from("audit_logs").insert({ + debt_id: debt.id, + action: "manual_response_sent", + details: { + subject: subject.trim(), + bodyLength: body.trim().length, + sentAt: new Date().toISOString(), + }, + }); + + toast.success("Response Sent", { + description: + "Your manual response has been recorded and the debt status updated.", + }); + + setOpen(false); + onResponseSent?.(); + } catch (error) { + console.error("Error sending manual response:", error); + toast.error("Failed to send response", { + description: "Please try again or contact support.", + }); + } finally { + setIsSending(false); + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + return ( + + + + + + + + + Manual Response Required + + + The AI couldn't determine the creditor's intent clearly. Please + review their response and compose a manual reply. + + + +
+ {/* Left Column: Creditor's Last Response */} +
+
+

+ Creditor's Last Response +

+ {lastMessage ? ( + + +
+ + {lastMessage.subject || "No Subject"} + + + {formatDate(lastMessage.created_at)} + +
+
+ + {lastMessage.from_email} → You +
+
+ +
+

+ {lastMessage.body} +

+
+ {lastMessage.ai_analysis && ( +
+
+
+ AI Analysis +
+
+ Intent:{" "} + {lastMessage.ai_analysis.intent || "unclear"} + {lastMessage.ai_analysis.confidence && ( + + ( + {Math.round( + lastMessage.ai_analysis.confidence * 100 + )} + % confidence) + + )} +
+ {lastMessage.ai_analysis.reasoning && ( +
+ {lastMessage.ai_analysis.reasoning} +
+ )} +
+
+ )} +
+
+ ) : ( +
+ +

No recent creditor response found

+
+ )} +
+
+ + {/* Right Column: Compose Response */} +
+
+

+ Compose Your Response +

+
+
+ + setSubject(e.target.value)} + placeholder="Enter subject line..." + className="mt-1" + /> +
+ +
+ +