mirror of
https://github.com/FranP-code/inbox-negotiator.git
synced 2025-10-13 00:42:26 +00:00
Adds AI-driven conversation tracking to debt negotiation
Introduces comprehensive conversation history with a new table and UI for tracking all negotiation emails, AI analysis, and financial outcomes. Enhances real-time updates, manages negotiation rounds, and supports new statuses for negotiation lifecycle. Integrates AI-powered extraction and response analysis to automate intent detection and outcome calculations, improving transparency and automation of debt resolution.
This commit is contained in:
@@ -14,12 +14,12 @@ body:json {
|
|||||||
{
|
{
|
||||||
"TextBody": "Dear customer, you owe $1,250.50 to CreditPlus for your January utility bills. Please pay by 2025-06-15 to avoid penalties.",
|
"TextBody": "Dear customer, you owe $1,250.50 to CreditPlus for your January utility bills. Please pay by 2025-06-15 to avoid penalties.",
|
||||||
"FromFull": {
|
"FromFull": {
|
||||||
"Email": "billing@creditplus.com",
|
"Email": "contact@francisco-pessano.com",
|
||||||
"Name": "CreditPlus Billing Department"
|
"Name": "CreditPlus Billing Department"
|
||||||
},
|
},
|
||||||
"ToFull": [
|
"ToFull": [
|
||||||
{
|
{
|
||||||
"Email": "franpessano1@gmail.com",
|
"Email": "contacto@francisco-pessano.com",
|
||||||
"Name": "",
|
"Name": "",
|
||||||
"MailboxHash": "ahoy"
|
"MailboxHash": "ahoy"
|
||||||
}
|
}
|
||||||
|
|||||||
586
src/components/ConversationTimeline.tsx
Normal file
586
src/components/ConversationTimeline.tsx
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
XCircle,
|
||||||
|
StopCircle,
|
||||||
|
Mail,
|
||||||
|
MailOpen,
|
||||||
|
MessageSquare,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
Building2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { supabase, type Debt } from "../lib/supabase";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
import { Separator } from "./ui/separator";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { formatCurrency } from "../lib/utils";
|
||||||
|
|
||||||
|
interface ConversationMessage {
|
||||||
|
id: string;
|
||||||
|
debt_id: string;
|
||||||
|
message_type: string;
|
||||||
|
direction: "inbound" | "outbound";
|
||||||
|
subject?: string;
|
||||||
|
body: string;
|
||||||
|
from_email?: string;
|
||||||
|
to_email?: string;
|
||||||
|
message_id?: string;
|
||||||
|
ai_analysis?: any;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversationTimelineProps {
|
||||||
|
debt: Debt;
|
||||||
|
onDebtUpdate?: (updatedDebt: Debt) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageTypeLabels = {
|
||||||
|
initial_debt: "Initial Debt Notice",
|
||||||
|
negotiation_sent: "Negotiation Sent",
|
||||||
|
response_received: "Response Received",
|
||||||
|
counter_offer: "Counter Offer",
|
||||||
|
acceptance: "Offer Accepted",
|
||||||
|
rejection: "Offer Rejected",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
received: "text-blue-600 dark:text-blue-400",
|
||||||
|
negotiating: "text-yellow-600 dark:text-yellow-400",
|
||||||
|
approved: "text-purple-600 dark:text-purple-400",
|
||||||
|
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",
|
||||||
|
accepted: "text-green-600 dark:text-green-400",
|
||||||
|
rejected: "text-red-600 dark:text-red-400",
|
||||||
|
settled: "text-green-600 dark:text-green-400",
|
||||||
|
failed: "text-red-600 dark:text-red-400",
|
||||||
|
opted_out: "text-gray-600 dark:text-gray-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
received: "Received",
|
||||||
|
negotiating: "Negotiating",
|
||||||
|
approved: "Approved",
|
||||||
|
sent: "Sent",
|
||||||
|
awaiting_response: "Awaiting Response",
|
||||||
|
counter_negotiating: "Counter Negotiating",
|
||||||
|
accepted: "Accepted",
|
||||||
|
rejected: "Rejected",
|
||||||
|
settled: "Settled",
|
||||||
|
failed: "Failed",
|
||||||
|
opted_out: "Opted Out",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConversationTimeline({
|
||||||
|
debt,
|
||||||
|
onDebtUpdate,
|
||||||
|
}: ConversationTimelineProps) {
|
||||||
|
const [messages, setMessages] = useState<ConversationMessage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConversationMessages();
|
||||||
|
|
||||||
|
// Set up real-time subscription for conversation messages
|
||||||
|
const channel = supabase
|
||||||
|
.channel(`conversation_messages:debt_id=eq.${debt.id}`)
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{
|
||||||
|
event: "*",
|
||||||
|
schema: "public",
|
||||||
|
table: "conversation_messages",
|
||||||
|
filter: `debt_id=eq.${debt.id}`,
|
||||||
|
},
|
||||||
|
(payload) => {
|
||||||
|
console.log("Real-time conversation message update:", payload);
|
||||||
|
|
||||||
|
if (payload.eventType === "INSERT") {
|
||||||
|
// Add new message to the list
|
||||||
|
const newMessage = payload.new as ConversationMessage;
|
||||||
|
setMessages((prev) => [...prev, newMessage]);
|
||||||
|
|
||||||
|
// Show toast notification for new messages
|
||||||
|
if (newMessage.direction === "inbound") {
|
||||||
|
const isAcceptance = newMessage.message_type === "acceptance";
|
||||||
|
toast.success(
|
||||||
|
isAcceptance ? "🎉 Offer Accepted!" : "New Response Received",
|
||||||
|
{
|
||||||
|
description: isAcceptance
|
||||||
|
? "Creditor accepted your negotiation offer!"
|
||||||
|
: `${
|
||||||
|
messageTypeLabels[newMessage.message_type] ||
|
||||||
|
"New message"
|
||||||
|
} from creditor`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (payload.eventType === "UPDATE") {
|
||||||
|
// Update existing message
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((msg) =>
|
||||||
|
msg.id === payload.new.id
|
||||||
|
? (payload.new as ConversationMessage)
|
||||||
|
: msg
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (payload.eventType === "DELETE") {
|
||||||
|
// Remove deleted message
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.filter((msg) => msg.id !== payload.old.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
// Set up real-time subscription for debt status changes
|
||||||
|
const debtChannel = supabase
|
||||||
|
.channel(`debt_status:id=eq.${debt.id}`)
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{
|
||||||
|
event: "UPDATE",
|
||||||
|
schema: "public",
|
||||||
|
table: "debts",
|
||||||
|
filter: `id=eq.${debt.id}`,
|
||||||
|
},
|
||||||
|
(payload) => {
|
||||||
|
console.log("Real-time debt status update:", payload);
|
||||||
|
// Notify parent component about debt update
|
||||||
|
if (onDebtUpdate) {
|
||||||
|
onDebtUpdate(payload.new as Debt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
// Cleanup subscriptions on unmount
|
||||||
|
return () => {
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
supabase.removeChannel(debtChannel);
|
||||||
|
};
|
||||||
|
}, [debt.id]);
|
||||||
|
|
||||||
|
const fetchConversationMessages = async () => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("conversation_messages")
|
||||||
|
.select("*")
|
||||||
|
.eq("debt_id", debt.id)
|
||||||
|
.order("created_at", { ascending: true });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error fetching conversation messages:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching conversation messages:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageIcon = (message: ConversationMessage) => {
|
||||||
|
if (message.direction === "outbound") {
|
||||||
|
return <Mail className="h-4 w-4" />;
|
||||||
|
} else {
|
||||||
|
switch (message.message_type) {
|
||||||
|
case "acceptance":
|
||||||
|
return <CheckCircle className="h-4 w-4" />;
|
||||||
|
case "rejection":
|
||||||
|
return <XCircle className="h-4 w-4" />;
|
||||||
|
case "counter_offer":
|
||||||
|
return <MessageSquare className="h-4 w-4" />;
|
||||||
|
default:
|
||||||
|
return <MailOpen className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageColor = (message: ConversationMessage) => {
|
||||||
|
if (message.direction === "outbound") {
|
||||||
|
return "text-blue-600 dark:text-blue-400";
|
||||||
|
} else {
|
||||||
|
switch (message.message_type) {
|
||||||
|
case "acceptance":
|
||||||
|
return "text-green-600 dark:text-green-400";
|
||||||
|
case "rejection":
|
||||||
|
return "text-red-600 dark:text-red-400";
|
||||||
|
case "counter_offer":
|
||||||
|
return "text-yellow-600 dark:text-yellow-400";
|
||||||
|
default:
|
||||||
|
return "text-gray-600 dark:text-gray-400";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSentimentIcon = (sentiment?: string) => {
|
||||||
|
switch (sentiment) {
|
||||||
|
case "positive":
|
||||||
|
return <TrendingUp className="h-3 w-3 text-green-500" />;
|
||||||
|
case "negative":
|
||||||
|
return <TrendingDown className="h-3 w-3 text-red-500" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5 animate-spin" />
|
||||||
|
Loading Conversation...
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5" />
|
||||||
|
Conversation Timeline
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className={statusColors[debt.status]}>
|
||||||
|
{statusLabels[debt.status]}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Round {debt.negotiation_round || 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>No conversation messages yet</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Messages will appear here as the negotiation progresses
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<div key={message.id} className="relative">
|
||||||
|
{/* Timeline line */}
|
||||||
|
{index < messages.length - 1 && (
|
||||||
|
<div className="absolute left-4 top-8 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
flex items-center justify-center w-8 h-8 rounded-full border-2 bg-white dark:bg-gray-800
|
||||||
|
${getMessageColor(message)} border-current
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{getMessageIcon(message)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-foreground">
|
||||||
|
{messageTypeLabels[message.message_type] ||
|
||||||
|
message.message_type}
|
||||||
|
</h4>
|
||||||
|
{message.ai_analysis?.sentiment &&
|
||||||
|
getSentimentIcon(message.ai_analysis.sentiment)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{formatDate(message.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300 mb-2">
|
||||||
|
{message.direction === "outbound" ? (
|
||||||
|
<>
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
<span>You → {message.to_email}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Building2 className="h-3 w-3" />
|
||||||
|
<span>{message.from_email} → You</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message.subject && (
|
||||||
|
<p className="text-sm font-medium text-gray-800 dark:text-gray-200 mb-2">
|
||||||
|
Subject: {message.subject}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 text-sm">
|
||||||
|
<p className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">
|
||||||
|
{message.body.length > 200
|
||||||
|
? `${message.body.substring(0, 200)}...`
|
||||||
|
: message.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Analysis */}
|
||||||
|
{message.ai_analysis && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{message.ai_analysis.intent && (
|
||||||
|
<div className="flex items-center gap-2 my-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
message.ai_analysis.intent === "acceptance"
|
||||||
|
? "default"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
className={`text-xs ${
|
||||||
|
message.ai_analysis.intent === "acceptance"
|
||||||
|
? "bg-green-600 text-white"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Intent: {message.ai_analysis.intent}
|
||||||
|
</Badge>
|
||||||
|
{message.ai_analysis.confidence && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{Math.round(
|
||||||
|
message.ai_analysis.confidence * 100
|
||||||
|
)}
|
||||||
|
% confidence
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!message.ai_analysis.extractedTerms
|
||||||
|
?.proposedAmount && (
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Proposed Amount: $
|
||||||
|
{message.ai_analysis.extractedTerms.proposedAmount}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show financial outcome for accepted offers */}
|
||||||
|
{message.ai_analysis.intent === "acceptance" &&
|
||||||
|
debt.metadata?.financialOutcome && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 mt-2">
|
||||||
|
{debt.metadata.financialOutcome.financialBenefit
|
||||||
|
?.type === "principal_reduction" ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="font-medium text-green-800 dark:text-green-200">
|
||||||
|
Principal Reduction Achieved
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
|
Original Debt:
|
||||||
|
</span>
|
||||||
|
<div className="font-medium">
|
||||||
|
$
|
||||||
|
{
|
||||||
|
debt.metadata.financialOutcome
|
||||||
|
.originalAmount
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
|
Settlement Amount:
|
||||||
|
</span>
|
||||||
|
<div className="font-medium">
|
||||||
|
$
|
||||||
|
{
|
||||||
|
debt.metadata.financialOutcome
|
||||||
|
.acceptedAmount
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
|
Total Savings:
|
||||||
|
</span>
|
||||||
|
<div className="font-medium text-green-600">
|
||||||
|
$
|
||||||
|
{
|
||||||
|
debt.metadata.financialOutcome
|
||||||
|
.actualSavings
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
|
Reduction:
|
||||||
|
</span>
|
||||||
|
<div className="font-medium text-green-600">
|
||||||
|
{
|
||||||
|
debt.metadata.financialOutcome
|
||||||
|
.financialBenefit.percentage
|
||||||
|
}
|
||||||
|
%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : debt.metadata.financialOutcome
|
||||||
|
.financialBenefit?.type ===
|
||||||
|
"payment_restructuring" ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Calendar className="h-4 w-4 text-blue-600" />
|
||||||
|
<span className="font-medium text-blue-800 dark:text-blue-200">
|
||||||
|
Payment Plan Restructured
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/30 rounded p-2">
|
||||||
|
<div className="font-medium text-blue-800 dark:text-blue-200 mb-1">
|
||||||
|
{
|
||||||
|
debt.metadata.financialOutcome
|
||||||
|
.financialBenefit.description
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{debt.metadata.financialOutcome
|
||||||
|
.financialBenefit.cashFlowBenefit && (
|
||||||
|
<div className="text-blue-600 dark:text-blue-300">
|
||||||
|
💰{" "}
|
||||||
|
{
|
||||||
|
debt.metadata.financialOutcome
|
||||||
|
.financialBenefit.cashFlowBenefit
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{debt.metadata.financialOutcome
|
||||||
|
.paymentStructure && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
|
Monthly Payment:
|
||||||
|
</span>
|
||||||
|
<div className="font-medium">
|
||||||
|
$
|
||||||
|
{
|
||||||
|
debt.metadata.financialOutcome
|
||||||
|
.paymentStructure.monthlyAmount
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
|
Term Length:
|
||||||
|
</span>
|
||||||
|
<div className="font-medium">
|
||||||
|
{
|
||||||
|
debt.metadata.financialOutcome
|
||||||
|
.paymentStructure
|
||||||
|
.numberOfPayments
|
||||||
|
}{" "}
|
||||||
|
months
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
|
Total Amount:
|
||||||
|
</span>
|
||||||
|
<div className="font-medium">
|
||||||
|
$
|
||||||
|
{formatCurrency(
|
||||||
|
debt.metadata.financialOutcome
|
||||||
|
.paymentStructure.totalAmount
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
|
Frequency:
|
||||||
|
</span>
|
||||||
|
<div className="font-medium capitalize">
|
||||||
|
{
|
||||||
|
debt.metadata.financialOutcome
|
||||||
|
.paymentStructure.frequency
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="font-medium text-green-800 dark:text-green-200">
|
||||||
|
Offer Accepted
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Settlement terms have been agreed upon.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-gray-600 dark:text-gray-300">
|
||||||
|
Total Messages: {messages.length}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-600 dark:text-gray-300">
|
||||||
|
Negotiation Round: {debt.negotiation_round || 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{debt.last_message_at && (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
|
Last Activity: {formatDate(debt.last_message_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { supabase, type Debt, type UserProfile } from "../lib/supabase";
|
import { supabase, type Debt, type UserProfile } from "../lib/supabase";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { DebtCard } from "./DebtCard";
|
import { DebtCard } from "./DebtCard";
|
||||||
import { DebtTimeline } from "./DebtTimeline";
|
import { ConversationTimeline } from "./ConversationTimeline";
|
||||||
import { OnboardingDialog } from "./OnboardingDialog";
|
import { OnboardingDialog } from "./OnboardingDialog";
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "./ui/card";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { Separator } from "./ui/separator";
|
import { Separator } from "./ui/separator";
|
||||||
@@ -21,10 +15,9 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
BarChart3,
|
|
||||||
LogOut,
|
|
||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { formatCurrency } from "../lib/utils";
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const [debts, setDebts] = useState<Debt[]>([]);
|
const [debts, setDebts] = useState<Debt[]>([]);
|
||||||
@@ -130,10 +123,13 @@ export function Dashboard() {
|
|||||||
const calculateStats = () => {
|
const calculateStats = () => {
|
||||||
const totalDebts = debts.length;
|
const totalDebts = debts.length;
|
||||||
const totalAmount = debts.reduce((sum, debt) => sum + debt.amount, 0);
|
const totalAmount = debts.reduce((sum, debt) => sum + debt.amount, 0);
|
||||||
const projectedSavings = debts.reduce(
|
const projectedSavings = debts.reduce((sum, debt) => {
|
||||||
(sum, debt) => sum + debt.projected_savings,
|
// Use actual savings for accepted debts, projected for others
|
||||||
0
|
if (debt.status === "accepted" && debt.metadata?.actualSavings?.amount) {
|
||||||
);
|
return sum + debt.metadata.actualSavings.amount;
|
||||||
|
}
|
||||||
|
return sum + debt.projected_savings;
|
||||||
|
}, 0);
|
||||||
const settledCount = debts.filter(
|
const settledCount = debts.filter(
|
||||||
(debt) => debt.status === "settled"
|
(debt) => debt.status === "settled"
|
||||||
).length;
|
).length;
|
||||||
@@ -157,23 +153,22 @@ export function Dashboard() {
|
|||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupedDebts = {
|
const groupedDebts = {
|
||||||
all: debts,
|
all: debts,
|
||||||
active: debts.filter((debt) =>
|
active: debts.filter((debt) =>
|
||||||
["received", "negotiating"].includes(debt.status)
|
[
|
||||||
|
"received",
|
||||||
|
"negotiating",
|
||||||
|
"approved",
|
||||||
|
"awaiting_response",
|
||||||
|
"counter_negotiating",
|
||||||
|
].includes(debt.status)
|
||||||
),
|
),
|
||||||
settled: debts.filter((debt) =>
|
settled: debts.filter((debt) =>
|
||||||
["settled", "approved", "sent"].includes(debt.status)
|
["settled", "accepted", "sent"].includes(debt.status)
|
||||||
),
|
),
|
||||||
failed: debts.filter((debt) =>
|
failed: debts.filter((debt) =>
|
||||||
["failed", "opted_out"].includes(debt.status)
|
["failed", "rejected", "opted_out"].includes(debt.status)
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -318,11 +313,14 @@ export function Dashboard() {
|
|||||||
{debtList.map((debt) => (
|
{debtList.map((debt) => (
|
||||||
<div key={debt.id} className="space-y-4">
|
<div key={debt.id} className="space-y-4">
|
||||||
<DebtCard debt={debt} onUpdate={fetchDebts} />
|
<DebtCard debt={debt} onUpdate={fetchDebts} />
|
||||||
<Card className="bg-gray-50 dark:bg-gray-800/50">
|
<ConversationTimeline
|
||||||
<CardContent className="p-4">
|
debt={debt}
|
||||||
<DebtTimeline debt={debt} />
|
onDebtUpdate={(debt) => {
|
||||||
</CardContent>
|
setDebts(
|
||||||
</Card>
|
debts.map((d) => (d.id === debt.id ? debt : d))
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Textarea } from "./ui/textarea";
|
|||||||
import { Label } from "./ui/label";
|
import { Label } from "./ui/label";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { supabase, type Debt, type DebtVariable } from "../lib/supabase";
|
import { supabase, type Debt, type DebtVariable } from "../lib/supabase";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { formatCurrency } from "../lib/utils";
|
||||||
|
|
||||||
interface DebtCardProps {
|
interface DebtCardProps {
|
||||||
debt: Debt;
|
debt: Debt;
|
||||||
@@ -59,6 +61,14 @@ const statusColors = {
|
|||||||
approved:
|
approved:
|
||||||
"bg-teal-100 text-teal-800 border-teal-200 dark:bg-teal-900/20 dark:text-teal-300 dark:border-teal-800",
|
"bg-teal-100 text-teal-800 border-teal-200 dark:bg-teal-900/20 dark:text-teal-300 dark:border-teal-800",
|
||||||
sent: "bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800",
|
sent: "bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800",
|
||||||
|
awaiting_response:
|
||||||
|
"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",
|
||||||
|
accepted:
|
||||||
|
"bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800",
|
||||||
|
rejected:
|
||||||
|
"bg-red-100 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800",
|
||||||
settled:
|
settled:
|
||||||
"bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800",
|
"bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800",
|
||||||
failed:
|
failed:
|
||||||
@@ -72,6 +82,10 @@ const statusLabels = {
|
|||||||
negotiating: "Negotiating",
|
negotiating: "Negotiating",
|
||||||
approved: "Approved",
|
approved: "Approved",
|
||||||
sent: "Sent",
|
sent: "Sent",
|
||||||
|
awaiting_response: "Awaiting Response",
|
||||||
|
counter_negotiating: "Counter Negotiating",
|
||||||
|
accepted: "Accepted",
|
||||||
|
rejected: "Rejected",
|
||||||
settled: "Settled",
|
settled: "Settled",
|
||||||
failed: "Failed",
|
failed: "Failed",
|
||||||
opted_out: "Opted Out",
|
opted_out: "Opted Out",
|
||||||
@@ -81,21 +95,20 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
const [isApproving, setIsApproving] = useState(false);
|
const [isApproving, setIsApproving] = useState(false);
|
||||||
const [isRejecting, setIsRejecting] = useState(false);
|
const [isRejecting, setIsRejecting] = useState(false);
|
||||||
const [userProfile, setUserProfile] = useState<any>(null);
|
const [userProfile, setUserProfile] = useState<any>(null);
|
||||||
const [hasServerToken, setHasServerToken] = useState(false);
|
const [hasServerToken, setHasServerToken] = useState<boolean | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
const isReadOnly =
|
const isReadOnly =
|
||||||
debt.status === "approved" ||
|
debt.status === "approved" ||
|
||||||
debt.status === "sent" ||
|
debt.status === "sent" ||
|
||||||
|
debt.status === "awaiting_response" ||
|
||||||
|
debt.status === "accepted" ||
|
||||||
|
debt.status === "rejected" ||
|
||||||
|
debt.status === "settled" ||
|
||||||
debt.status === "failed" ||
|
debt.status === "failed" ||
|
||||||
debt.status === "opted_out";
|
debt.status === "opted_out";
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString("en-US", {
|
return new Date(dateString).toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -466,9 +479,11 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
<div className="flex justify-end gap-2 border-t pt-4">
|
<div className="flex justify-end gap-2 border-t pt-4">
|
||||||
<Button variant="outline" onClick={() => setIsEditing(false)}>
|
<DialogClose>
|
||||||
Cancel
|
<Button variant="outline" onClick={() => setIsEditing(false)}>
|
||||||
</Button>
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
<Button onClick={handleSave} disabled={isSaving}>
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
{isSaving ? "Saving..." : "Save Changes"}
|
{isSaving ? "Saving..." : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -663,8 +678,8 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
|
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-0.5">
|
||||||
<DollarSign className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
<DollarSign className="h-5 w-5 text-gray-500 dark:text-gray-400 my-auto" />
|
||||||
<span className="text-2xl font-bold text-gray-900 dark:text-foreground">
|
<span className="text-2xl font-bold text-gray-900 dark:text-foreground">
|
||||||
{formatCurrency(debt.amount)}
|
{formatCurrency(debt.amount)}
|
||||||
</span>
|
</span>
|
||||||
@@ -710,7 +725,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
{/* Approve/Reject Buttons */}
|
{/* Approve/Reject Buttons */}
|
||||||
{showApproveRejectButtons() && (
|
{showApproveRejectButtons() && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{!hasServerToken && (
|
{hasServerToken === false && (
|
||||||
<div className="flex items-center gap-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
<div className="flex items-center gap-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||||
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||||
<span className="text-sm text-amber-700 dark:text-amber-300 flex-1">
|
<span className="text-sm text-amber-700 dark:text-amber-300 flex-1">
|
||||||
@@ -721,7 +736,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => (window.location.href = "/configuration")}
|
onClick={() => (window.location.href = "/configuration")}
|
||||||
className="text-amber-700 border-amber-300 hover:bg-amber-100"
|
className="text-amber-700 dark:text-amber-500 border-amber-300"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3 w-3 mr-1" />
|
<ExternalLink className="h-3 w-3 mr-1" />
|
||||||
Settings
|
Settings
|
||||||
@@ -757,7 +772,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => handleApprove()}
|
onClick={() => handleApprove()}
|
||||||
className="bg-green-600 hover:bg-green-700"
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
>
|
>
|
||||||
Send Email
|
Send Email
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|||||||
@@ -1,26 +1,83 @@
|
|||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
StopCircle,
|
StopCircle,
|
||||||
|
Send,
|
||||||
|
MessageSquare,
|
||||||
|
ThumbsUp,
|
||||||
|
ThumbsDown,
|
||||||
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Debt } from "../lib/supabase";
|
import type { Debt } from "../lib/supabase";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
interface DebtTimelineProps {
|
interface DebtTimelineProps {
|
||||||
debt: Debt;
|
debt: Debt;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineSteps = [
|
interface ConversationMessage {
|
||||||
{ key: "received", label: "Email Received", icon: CheckCircle },
|
id: string;
|
||||||
{ key: "negotiating", label: "Negotiating", icon: Clock },
|
message_type: string;
|
||||||
{ key: "settled", label: "Settled", icon: CheckCircle },
|
direction: "inbound" | "outbound";
|
||||||
];
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
from_email: string;
|
||||||
|
to_email: string;
|
||||||
|
ai_analysis?: any;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimelineSteps = (debt: Debt) => {
|
||||||
|
const baseSteps = [
|
||||||
|
{ key: "received", label: "Debt Email Received", icon: MessageSquare },
|
||||||
|
{ key: "negotiating", label: "AI Response Generated", icon: Clock },
|
||||||
|
{ key: "approved", label: "Response Approved", icon: CheckCircle },
|
||||||
|
{ key: "sent", label: "Negotiation Email Sent", icon: Send },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add dynamic steps based on conversation
|
||||||
|
if (debt.status === "counter_negotiating" || debt.negotiation_round > 1) {
|
||||||
|
baseSteps.push({
|
||||||
|
key: "counter_negotiating",
|
||||||
|
label: "Counter-Negotiating",
|
||||||
|
icon: RefreshCw,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debt.status === "accepted" || debt.status === "settled") {
|
||||||
|
baseSteps.push({
|
||||||
|
key: "accepted",
|
||||||
|
label: "Offer Accepted",
|
||||||
|
icon: ThumbsUp,
|
||||||
|
});
|
||||||
|
baseSteps.push({
|
||||||
|
key: "settled",
|
||||||
|
label: "Debt Settled",
|
||||||
|
icon: CheckCircle,
|
||||||
|
});
|
||||||
|
} else if (debt.status === "rejected") {
|
||||||
|
baseSteps.push({
|
||||||
|
key: "rejected",
|
||||||
|
label: "Offer Rejected",
|
||||||
|
icon: ThumbsDown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseSteps;
|
||||||
|
};
|
||||||
|
|
||||||
const statusIcons = {
|
const statusIcons = {
|
||||||
received: CheckCircle,
|
received: MessageSquare,
|
||||||
negotiating: Clock,
|
negotiating: Clock,
|
||||||
|
approved: CheckCircle,
|
||||||
|
sent: Send,
|
||||||
|
awaiting_response: Clock,
|
||||||
|
counter_negotiating: RefreshCw,
|
||||||
|
accepted: ThumbsUp,
|
||||||
|
rejected: ThumbsDown,
|
||||||
settled: CheckCircle,
|
settled: CheckCircle,
|
||||||
failed: XCircle,
|
failed: XCircle,
|
||||||
opted_out: StopCircle,
|
opted_out: StopCircle,
|
||||||
@@ -29,20 +86,139 @@ const statusIcons = {
|
|||||||
const statusColors = {
|
const statusColors = {
|
||||||
received: "text-blue-600 dark:text-blue-400",
|
received: "text-blue-600 dark:text-blue-400",
|
||||||
negotiating: "text-yellow-600 dark:text-yellow-400",
|
negotiating: "text-yellow-600 dark:text-yellow-400",
|
||||||
|
approved: "text-green-600 dark:text-green-400",
|
||||||
|
sent: "text-purple-600 dark:text-purple-400",
|
||||||
|
awaiting_response: "text-orange-600 dark:text-orange-400",
|
||||||
|
counter_negotiating: "text-indigo-600 dark:text-indigo-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",
|
settled: "text-green-600 dark:text-green-400",
|
||||||
failed: "text-red-600 dark:text-red-400",
|
failed: "text-red-600 dark:text-red-400",
|
||||||
opted_out: "text-gray-600 dark:text-gray-400",
|
opted_out: "text-gray-600 dark:text-gray-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DebtTimeline({ debt }: DebtTimelineProps) {
|
export function DebtTimeline({ debt }: DebtTimelineProps) {
|
||||||
|
const [conversationMessages, setConversationMessages] = useState<
|
||||||
|
ConversationMessage[]
|
||||||
|
>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchConversationMessages = async () => {
|
||||||
|
try {
|
||||||
|
const supabase = createClient(
|
||||||
|
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||||
|
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("conversation_messages")
|
||||||
|
.select("*")
|
||||||
|
.eq("debt_id", debt.id)
|
||||||
|
.order("created_at", { ascending: true });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error fetching conversation messages:", error);
|
||||||
|
} else {
|
||||||
|
setConversationMessages(data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchConversationMessages();
|
||||||
|
}, [debt.id]);
|
||||||
|
|
||||||
|
const timelineSteps = getTimelineSteps(debt);
|
||||||
const currentStepIndex = timelineSteps.findIndex(
|
const currentStepIndex = timelineSteps.findIndex(
|
||||||
(step) => step.key === debt.status
|
(step) => step.key === debt.status
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const formatDateTime = (dateString: string) => {
|
||||||
<div className="space-y-4">
|
const date = new Date(dateString);
|
||||||
<h3 className="text-lg font-semibold">Progress Timeline</h3>
|
return {
|
||||||
|
date: date.toLocaleDateString(),
|
||||||
|
time: date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusDescription = (status: string, round?: number) => {
|
||||||
|
switch (status) {
|
||||||
|
case "received":
|
||||||
|
return "Debt collection email received and parsed";
|
||||||
|
case "negotiating":
|
||||||
|
return "AI analyzing debt and generating negotiation strategy";
|
||||||
|
case "approved":
|
||||||
|
return "Negotiation response approved and ready to send";
|
||||||
|
case "sent":
|
||||||
|
return "Negotiation email sent to creditor";
|
||||||
|
case "awaiting_response":
|
||||||
|
return "Waiting for creditor's response";
|
||||||
|
case "counter_negotiating":
|
||||||
|
return `Round ${round || 1} - Analyzing creditor's counter-offer`;
|
||||||
|
case "accepted":
|
||||||
|
return "Creditor accepted the negotiation terms";
|
||||||
|
case "rejected":
|
||||||
|
return "Creditor rejected the offer - manual review needed";
|
||||||
|
case "settled":
|
||||||
|
return "Debt successfully settled with agreed terms";
|
||||||
|
case "failed":
|
||||||
|
return "Negotiation failed - escalated for manual handling";
|
||||||
|
default:
|
||||||
|
return "Processing...";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Negotiation Timeline</h3>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Round {debt.negotiation_round || 1} • {debt.conversation_count || 0}{" "}
|
||||||
|
messages
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Status Overview */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-full ${statusColors[
|
||||||
|
debt.status as keyof typeof statusColors
|
||||||
|
]
|
||||||
|
?.replace("text-", "bg-")
|
||||||
|
.replace("dark:text-", "dark:bg-")} bg-opacity-20`}
|
||||||
|
>
|
||||||
|
{React.createElement(
|
||||||
|
statusIcons[debt.status as keyof typeof statusIcons] || Clock,
|
||||||
|
{
|
||||||
|
className: `h-5 w-5 ${
|
||||||
|
statusColors[debt.status as keyof typeof statusColors]
|
||||||
|
}`,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium capitalize">
|
||||||
|
{debt.status.replace("_", " ")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{getStatusDescription(debt.status, debt.negotiation_round)}
|
||||||
|
</div>
|
||||||
|
{debt.last_message_at && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Last updated: {formatDateTime(debt.last_message_at).date} at{" "}
|
||||||
|
{formatDateTime(debt.last_message_at).time}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Steps */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{timelineSteps.map((step, index) => {
|
{timelineSteps.map((step, index) => {
|
||||||
const isCompleted = index <= currentStepIndex;
|
const isCompleted = index <= currentStepIndex;
|
||||||
@@ -50,90 +226,138 @@ export function DebtTimeline({ debt }: DebtTimelineProps) {
|
|||||||
const Icon = step.icon;
|
const Icon = step.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={step.key} className="flex items-center gap-3">
|
<div key={step.key} className="flex items-start gap-3">
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
flex items-center justify-center w-8 h-8 rounded-full border-2
|
flex items-center justify-center w-8 h-8 rounded-full border-2 mt-1
|
||||||
${
|
${
|
||||||
isCompleted
|
isCompleted
|
||||||
? "bg-primary border-primary text-white"
|
? "bg-primary border-primary text-white"
|
||||||
: "border-gray-300 dark:border-gray-600 text-gray-300 dark:text-gray-600"
|
: "border-gray-300 dark:border-gray-600 text-gray-300 dark:text-gray-600"
|
||||||
}
|
}
|
||||||
${isActive ? "ring-2 ring-primary/20" : ""}
|
${isActive ? "ring-2 ring-primary/20" : ""}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
font-medium
|
font-medium
|
||||||
${
|
${
|
||||||
isCompleted
|
isCompleted
|
||||||
? "text-gray-900 dark:text-foreground"
|
? "text-gray-900 dark:text-foreground"
|
||||||
: "text-gray-400 dark:text-gray-500"
|
: "text-gray-400 dark:text-gray-500"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{step.label}
|
{step.label}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-1">
|
<div className="text-sm text-gray-600 dark:text-gray-300 mt-1">
|
||||||
{debt.status === "received" &&
|
{getStatusDescription(debt.status, debt.negotiation_round)}
|
||||||
"Processing email and generating response..."}
|
|
||||||
{debt.status === "negotiating" &&
|
|
||||||
"Response generated, waiting for creditor reply"}
|
|
||||||
{debt.status === "settled" && "Payment plan agreed upon"}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isActive && (
|
{isCompleted && debt.updated_at && (
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||||
{new Date(debt.updated_at).toLocaleDateString()}
|
<div>{formatDateTime(debt.updated_at).date}</div>
|
||||||
|
<div>{formatDateTime(debt.updated_at).time}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Special cases for failed and opted_out */}
|
|
||||||
{(debt.status === "failed" || debt.status === "opted_out") && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
flex items-center justify-center w-8 h-8 rounded-full border-2
|
|
||||||
${
|
|
||||||
debt.status === "failed"
|
|
||||||
? "border-red-500 text-red-500 dark:border-red-400 dark:text-red-400"
|
|
||||||
: "border-gray-500 text-gray-500 dark:border-gray-400 dark:text-gray-400"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{React.createElement(statusIcons[debt.status], {
|
|
||||||
className: "h-4 w-4",
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className={`font-medium ${statusColors[debt.status]}`}>
|
|
||||||
{debt.status === "failed" ? "Negotiation Failed" : "Opted Out"}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-1">
|
|
||||||
{debt.status === "failed"
|
|
||||||
? "Creditor declined the negotiation proposal"
|
|
||||||
: "User requested to stop communication"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{new Date(debt.updated_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Conversation Messages */}
|
||||||
|
{!loading && conversationMessages.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-md font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Conversation History
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{conversationMessages.map((message) => {
|
||||||
|
const isOutbound = message.direction === "outbound";
|
||||||
|
const datetime = formatDateTime(message.created_at);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`flex ${
|
||||||
|
isOutbound ? "justify-end" : "justify-start"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
|
||||||
|
isOutbound
|
||||||
|
? "bg-primary text-white"
|
||||||
|
: "bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs opacity-75 mb-1">
|
||||||
|
{isOutbound ? "You" : "Creditor"} • {datetime.date}{" "}
|
||||||
|
{datetime.time}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium mb-1">
|
||||||
|
{message.subject}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm opacity-90">
|
||||||
|
{message.body.length > 100
|
||||||
|
? `${message.body.substring(0, 100)}...`
|
||||||
|
: message.body}
|
||||||
|
</div>
|
||||||
|
{message.ai_analysis && (
|
||||||
|
<div className="text-xs mt-2 opacity-75">
|
||||||
|
AI: {message.ai_analysis.intent || "Analyzed"}
|
||||||
|
{message.ai_analysis.confidence &&
|
||||||
|
` (${Math.round(
|
||||||
|
message.ai_analysis.confidence * 100
|
||||||
|
)}% confidence)`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Savings Summary */}
|
||||||
|
{(debt.projected_savings > 0 ||
|
||||||
|
(debt.actual_savings && debt.actual_savings > 0)) && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||||
|
<h4 className="text-md font-medium text-green-800 dark:text-green-200 mb-2">
|
||||||
|
Savings Summary
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
{debt.projected_savings > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-green-600 dark:text-green-400 font-medium">
|
||||||
|
Projected Savings
|
||||||
|
</div>
|
||||||
|
<div className="text-green-800 dark:text-green-200">
|
||||||
|
${debt.projected_savings.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{debt.actual_savings && debt.actual_savings > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-green-600 dark:text-green-400 font-medium">
|
||||||
|
Actual Savings
|
||||||
|
</div>
|
||||||
|
<div className="text-green-800 dark:text-green-200">
|
||||||
|
${debt.actual_savings.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
212
src/components/ExtractionTester.tsx
Normal file
212
src/components/ExtractionTester.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Textarea } from "./ui/textarea";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { TestTube, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function ExtractionTester() {
|
||||||
|
const [testEmail, setTestEmail] = useState(`Thank you for your payment arrangement request. We are pleased to offer you a structured payment plan. We can accept $250 per month for 18 months, totaling $4,500. This arrangement will allow you to resolve this matter with manageable monthly payments.`);
|
||||||
|
const [result, setResult] = useState<any>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const testExtraction = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/supabase/functions/test-extraction', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ testEmail }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setResult(data);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
toast.success("Extraction Test Complete", {
|
||||||
|
description: `Found ${Object.keys(data.extractedTerms || {}).length} extracted terms`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error("Extraction Test Failed", {
|
||||||
|
description: data.error || "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error testing extraction:", error);
|
||||||
|
toast.error("Test Failed", {
|
||||||
|
description: "Failed to test extraction",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const presetEmails = [
|
||||||
|
{
|
||||||
|
name: "Payment Plan",
|
||||||
|
content: "Thank you for your payment arrangement request. We are pleased to offer you a structured payment plan. We can accept $250 per month for 18 months, totaling $4,500. This arrangement will allow you to resolve this matter with manageable monthly payments."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Settlement Offer",
|
||||||
|
content: "We are pleased to accept your proposed settlement offer of $3,200. We will process the settlement as discussed and consider this matter resolved upon receipt of payment."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Counter Offer",
|
||||||
|
content: "Thank you for your proposal. However, we can only accept a settlement of $4,000 or a payment plan of $300 per month for 15 months. Please let us know which option works better for you."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rejection",
|
||||||
|
content: "Thank you for your payment proposal. Unfortunately, we cannot accept the terms you have proposed. We require the full amount of $5,000 to be paid within 30 days."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-4xl mx-auto">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TestTube className="h-5 w-5" />
|
||||||
|
AI Extraction Tester
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Preset Emails */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-2 block">Preset Test Emails:</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{presetEmails.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.name}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTestEmail(preset.content)}
|
||||||
|
>
|
||||||
|
{preset.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Input */}
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-2 block">Test Email Content:</label>
|
||||||
|
<Textarea
|
||||||
|
value={testEmail}
|
||||||
|
onChange={(e) => setTestEmail(e.target.value)}
|
||||||
|
placeholder="Enter email content to test extraction..."
|
||||||
|
rows={4}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Button */}
|
||||||
|
<Button
|
||||||
|
onClick={testExtraction}
|
||||||
|
disabled={isLoading || !testEmail.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Testing Extraction...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TestTube className="h-4 w-4 mr-2" />
|
||||||
|
Test AI Extraction
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="font-medium mb-2">Extraction Results:</h3>
|
||||||
|
|
||||||
|
{/* Intent & Confidence */}
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Badge variant={result.analysis?.intent === "acceptance" ? "default" : "secondary"}>
|
||||||
|
Intent: {result.analysis?.intent}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
Confidence: {Math.round((result.analysis?.confidence || 0) * 100)}%
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
Sentiment: {result.analysis?.sentiment}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Extracted Terms */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<h4 className="font-medium mb-2">Extracted Terms:</h4>
|
||||||
|
<pre className="text-sm overflow-auto">
|
||||||
|
{JSON.stringify(result.extractedTerms, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Terms Detail */}
|
||||||
|
{result.extractedTerms?.paymentTerms && (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||||
|
<h4 className="font-medium mb-2">Payment Plan Details:</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
{result.extractedTerms.paymentTerms.monthlyAmount && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Monthly:</span>
|
||||||
|
<div className="font-medium">${result.extractedTerms.paymentTerms.monthlyAmount}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.extractedTerms.paymentTerms.numberOfPayments && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Payments:</span>
|
||||||
|
<div className="font-medium">{result.extractedTerms.paymentTerms.numberOfPayments}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.extractedTerms.paymentTerms.totalAmount && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Total:</span>
|
||||||
|
<div className="font-medium">${result.extractedTerms.paymentTerms.totalAmount}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.extractedTerms.paymentTerms.paymentFrequency && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Frequency:</span>
|
||||||
|
<div className="font-medium">{result.extractedTerms.paymentTerms.paymentFrequency}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Proposed Amount */}
|
||||||
|
{result.extractedTerms?.proposedAmount && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||||
|
<h4 className="font-medium mb-2">Settlement Amount:</h4>
|
||||||
|
<div className="text-lg font-bold text-green-600">
|
||||||
|
${result.extractedTerms.proposedAmount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reasoning */}
|
||||||
|
{result.analysis?.reasoning && (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<h4 className="font-medium mb-2">AI Reasoning:</h4>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{result.analysis.reasoning}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/components/RealtimeTestButton.tsx
Normal file
121
src/components/RealtimeTestButton.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { supabase } from "../lib/supabase";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MessageSquare } from "lucide-react";
|
||||||
|
|
||||||
|
interface RealtimeTestButtonProps {
|
||||||
|
debtId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RealtimeTestButton({ debtId }: RealtimeTestButtonProps) {
|
||||||
|
const [isSimulating, setIsSimulating] = useState(false);
|
||||||
|
|
||||||
|
const simulateIncomingMessage = async () => {
|
||||||
|
setIsSimulating(true);
|
||||||
|
try {
|
||||||
|
// Randomly choose between principal reduction and payment restructuring
|
||||||
|
const isPaymentPlan = Math.random() > 0.5;
|
||||||
|
|
||||||
|
const testMessage = isPaymentPlan ? {
|
||||||
|
// Payment restructuring scenario
|
||||||
|
debt_id: debtId,
|
||||||
|
message_type: "acceptance",
|
||||||
|
direction: "inbound",
|
||||||
|
subject: "Re: Payment Arrangement Request - Payment Plan Approved",
|
||||||
|
body: "Thank you for your payment arrangement request. We are pleased to offer you a structured payment plan. We can accept $250 per month for 18 months, totaling $4,500. This arrangement will allow you to resolve this matter with manageable monthly payments while providing you with extended terms.",
|
||||||
|
from_email: "collections@testcreditor.com",
|
||||||
|
to_email: "user@example.com",
|
||||||
|
ai_analysis: {
|
||||||
|
intent: "acceptance",
|
||||||
|
sentiment: "positive",
|
||||||
|
confidence: 0.92,
|
||||||
|
extractedTerms: {
|
||||||
|
proposedAmount: 4500,
|
||||||
|
proposedPaymentPlan: "monthly payment plan",
|
||||||
|
paymentTerms: {
|
||||||
|
monthlyAmount: 250,
|
||||||
|
numberOfPayments: 18,
|
||||||
|
totalAmount: 4500,
|
||||||
|
paymentFrequency: "monthly",
|
||||||
|
interestRate: 0
|
||||||
|
},
|
||||||
|
deadline: null,
|
||||||
|
conditions: [
|
||||||
|
"structured payment plan",
|
||||||
|
"manageable monthly payments",
|
||||||
|
"extended terms"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reasoning: "Creditor has accepted a payment restructuring plan with extended terms",
|
||||||
|
suggestedNextAction: "mark_settled",
|
||||||
|
requiresUserReview: false,
|
||||||
|
},
|
||||||
|
} : {
|
||||||
|
// Principal reduction scenario
|
||||||
|
debt_id: debtId,
|
||||||
|
message_type: "acceptance",
|
||||||
|
direction: "inbound",
|
||||||
|
subject: "Re: Payment Arrangement Request - Settlement Accepted",
|
||||||
|
body: "Thank you for your payment arrangement request. We are pleased to accept your proposed settlement offer of $3,200. We will process the settlement as discussed and consider this matter resolved upon receipt of payment.",
|
||||||
|
from_email: "collections@testcreditor.com",
|
||||||
|
to_email: "user@example.com",
|
||||||
|
ai_analysis: {
|
||||||
|
intent: "acceptance",
|
||||||
|
sentiment: "positive",
|
||||||
|
confidence: 0.95,
|
||||||
|
extractedTerms: {
|
||||||
|
proposedAmount: 3200,
|
||||||
|
proposedPaymentPlan: "lump sum settlement",
|
||||||
|
deadline: null,
|
||||||
|
conditions: [
|
||||||
|
"settlement accepted",
|
||||||
|
"matter resolved upon payment",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
reasoning: "Creditor has accepted the proposed settlement offer with principal reduction",
|
||||||
|
suggestedNextAction: "mark_settled",
|
||||||
|
requiresUserReview: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("conversation_messages")
|
||||||
|
.insert(testMessage);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
isPaymentPlan ? "🏦 Payment Plan Simulated" : "🎉 Settlement Simulated",
|
||||||
|
{
|
||||||
|
description: isPaymentPlan
|
||||||
|
? "A payment restructuring scenario has been simulated"
|
||||||
|
: "A principal reduction scenario has been simulated",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error simulating message:", error);
|
||||||
|
toast.error("Simulation Failed", {
|
||||||
|
description: "Failed to simulate incoming message",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSimulating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={simulateIncomingMessage}
|
||||||
|
disabled={isSimulating}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
{isSimulating ? "Simulating..." : "Test Real-time"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@ export type Debt = {
|
|||||||
| "negotiating"
|
| "negotiating"
|
||||||
| "approved"
|
| "approved"
|
||||||
| "sent"
|
| "sent"
|
||||||
|
| "awaiting_response"
|
||||||
|
| "counter_negotiating"
|
||||||
|
| "accepted"
|
||||||
|
| "rejected"
|
||||||
| "settled"
|
| "settled"
|
||||||
| "failed"
|
| "failed"
|
||||||
| "opted_out";
|
| "opted_out";
|
||||||
@@ -35,6 +39,11 @@ export type Debt = {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
due_date?: string | null;
|
due_date?: string | null;
|
||||||
|
conversation_count?: number;
|
||||||
|
last_message_at?: string;
|
||||||
|
negotiation_round?: number;
|
||||||
|
prospected_savings?: number;
|
||||||
|
actual_savings?: number;
|
||||||
metadata?: Record<string, any> | null;
|
metadata?: Record<string, any> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,3 +93,24 @@ export type DebtVariable = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConversationMessage = {
|
||||||
|
id: string;
|
||||||
|
debt_id: string;
|
||||||
|
message_type:
|
||||||
|
| "initial_debt"
|
||||||
|
| "negotiation_sent"
|
||||||
|
| "response_received"
|
||||||
|
| "counter_offer"
|
||||||
|
| "acceptance"
|
||||||
|
| "rejection";
|
||||||
|
direction: "inbound" | "outbound";
|
||||||
|
subject?: string;
|
||||||
|
body: string;
|
||||||
|
from_email?: string;
|
||||||
|
to_email?: string;
|
||||||
|
message_id?: string;
|
||||||
|
ai_analysis?: Record<string, any>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { clsx, type ClassValue } from 'clsx';
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(amount).split("$")[1];
|
||||||
|
};
|
||||||
|
|||||||
@@ -141,6 +141,171 @@ async function incrementEmailUsage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if incoming email is a response to existing negotiation
|
||||||
|
async function checkForExistingNegotiation(
|
||||||
|
fromEmail: string,
|
||||||
|
toEmail: string,
|
||||||
|
supabaseAdmin: any,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Look for debts where we've sent emails to this fromEmail and are awaiting response
|
||||||
|
// Include multiple statuses that indicate we're in an active negotiation
|
||||||
|
const { data: debts, error } = await supabaseAdmin
|
||||||
|
.from("debts")
|
||||||
|
.select("*")
|
||||||
|
.in("status", ["sent", "awaiting_response", "counter_negotiating"])
|
||||||
|
.contains("metadata", { fromEmail: fromEmail, toEmail: toEmail })
|
||||||
|
.order("last_message_at", { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error checking for existing negotiation:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the most recent debt that matches
|
||||||
|
return debts && debts.length > 0 ? debts[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in checkForExistingNegotiation:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle response to existing negotiation
|
||||||
|
async function handleNegotiationResponse(
|
||||||
|
debt: any,
|
||||||
|
emailData: any,
|
||||||
|
supabaseAdmin: any,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const textBody = emailData.TextBody || emailData.HtmlBody || "";
|
||||||
|
const fromEmail = emailData.FromFull?.Email || emailData.From || "unknown";
|
||||||
|
const subject = emailData.Subject || "";
|
||||||
|
const messageId = emailData.MessageID || `inbound-${Date.now()}`;
|
||||||
|
|
||||||
|
// First, record this message in the conversation
|
||||||
|
await supabaseAdmin.from("conversation_messages").insert({
|
||||||
|
debt_id: debt.id,
|
||||||
|
message_type: "response_received",
|
||||||
|
direction: "inbound",
|
||||||
|
subject: subject,
|
||||||
|
body: textBody,
|
||||||
|
from_email: fromEmail,
|
||||||
|
to_email: emailData.ToFull?.[0]?.Email || emailData.To || "",
|
||||||
|
message_id: messageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update debt conversation tracking
|
||||||
|
await supabaseAdmin
|
||||||
|
.from("debts")
|
||||||
|
.update({
|
||||||
|
conversation_count: debt.conversation_count + 1,
|
||||||
|
last_message_at: new Date().toISOString(),
|
||||||
|
status: "counter_negotiating", // Temporary status while analyzing
|
||||||
|
})
|
||||||
|
.eq("id", debt.id);
|
||||||
|
|
||||||
|
// Call the analyze-response function
|
||||||
|
const supabaseUrl = process.env.SUPABASE_URL ||
|
||||||
|
import.meta.env.PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ||
|
||||||
|
import.meta.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
if (supabaseUrl && supabaseServiceKey) {
|
||||||
|
const analyzeUrl = `${supabaseUrl}/functions/v1/analyze-response`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(analyzeUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${supabaseServiceKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
debtId: debt.id,
|
||||||
|
fromEmail,
|
||||||
|
subject,
|
||||||
|
body: textBody,
|
||||||
|
messageId: messageId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
console.log("Response analysis completed:", result);
|
||||||
|
|
||||||
|
// Update the conversation message with AI analysis
|
||||||
|
// !MAYBE NEEDED
|
||||||
|
// await supabaseAdmin
|
||||||
|
// .from("conversation_messages")
|
||||||
|
// .update({
|
||||||
|
// ai_analysis: result.analysis,
|
||||||
|
// message_type: result.analysis?.intent === "acceptance"
|
||||||
|
// ? "acceptance"
|
||||||
|
// : result.analysis?.intent === "rejection"
|
||||||
|
// ? "rejection"
|
||||||
|
// : "response_received",
|
||||||
|
// })
|
||||||
|
// .eq("message_id", messageId);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: "Negotiation response processed",
|
||||||
|
analysis: result.analysis,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Error calling analyze-response function:",
|
||||||
|
await response.text(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (analyzeError) {
|
||||||
|
console.error("Error calling analyze-response function:", analyzeError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: just log the response and mark for manual review
|
||||||
|
await supabaseAdmin.from("audit_logs").insert({
|
||||||
|
debt_id: debt.id,
|
||||||
|
action: "response_received_fallback",
|
||||||
|
details: {
|
||||||
|
fromEmail,
|
||||||
|
subject,
|
||||||
|
bodyPreview: textBody.substring(0, 200),
|
||||||
|
requiresManualReview: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update status to require user review
|
||||||
|
await supabaseAdmin
|
||||||
|
.from("debts")
|
||||||
|
.update({ status: "awaiting_response" })
|
||||||
|
.eq("id", debt.id);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true, message: "Response logged" }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error handling negotiation response:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Failed to process negotiation response" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
// Create service role client for webhook operations (bypasses RLS)
|
// Create service role client for webhook operations (bypasses RLS)
|
||||||
@@ -180,6 +345,21 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
return new Response("No matching user found", { status: 200 });
|
return new Response("No matching user found", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a response to an existing negotiation
|
||||||
|
const existingDebt = await checkForExistingNegotiation(
|
||||||
|
fromEmail,
|
||||||
|
toEmail,
|
||||||
|
supabaseAdmin,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log({ existingDebt, fromEmail, toEmail });
|
||||||
|
if (existingDebt) {
|
||||||
|
console.log(
|
||||||
|
`Found existing negotiation for debt ${existingDebt.id}, analyzing response...`,
|
||||||
|
);
|
||||||
|
return await handleNegotiationResponse(existingDebt, data, supabaseAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
// Increment email processing usage
|
// Increment email processing usage
|
||||||
await incrementEmailUsage(userId, supabaseAdmin);
|
await incrementEmailUsage(userId, supabaseAdmin);
|
||||||
|
|
||||||
@@ -248,6 +428,9 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
status: "received",
|
status: "received",
|
||||||
description: debtInfo.description,
|
description: debtInfo.description,
|
||||||
due_date: debtInfo.dueDate,
|
due_date: debtInfo.dueDate,
|
||||||
|
conversation_count: 1,
|
||||||
|
last_message_at: new Date().toISOString(),
|
||||||
|
negotiation_round: 1,
|
||||||
metadata: {
|
metadata: {
|
||||||
isDebtCollection: debtInfo.isDebtCollection,
|
isDebtCollection: debtInfo.isDebtCollection,
|
||||||
subject: data.Subject,
|
subject: data.Subject,
|
||||||
@@ -258,6 +441,20 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
if (!insertError && insertedDebt) {
|
||||||
|
// Record the initial debt email as the first conversation message
|
||||||
|
await supabaseAdmin.from("conversation_messages").insert({
|
||||||
|
debt_id: insertedDebt.id,
|
||||||
|
message_type: "initial_debt",
|
||||||
|
direction: "inbound",
|
||||||
|
subject: data.Subject,
|
||||||
|
body: textBody,
|
||||||
|
from_email: fromEmail,
|
||||||
|
to_email: toEmail,
|
||||||
|
message_id: data.MessageID || `initial-${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (insertError) {
|
if (insertError) {
|
||||||
console.error("Error inserting debt:", insertError);
|
console.error("Error inserting debt:", insertError);
|
||||||
const errorInfo = handleDatabaseError(insertError);
|
const errorInfo = handleDatabaseError(insertError);
|
||||||
|
|||||||
754
supabase/functions/analyze-response/index.ts
Normal file
754
supabase/functions/analyze-response/index.ts
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
import { generateObject } from "https://esm.sh/ai@3.4.7";
|
||||||
|
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
|
||||||
|
import { z } from "https://esm.sh/zod@3.22.4";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers":
|
||||||
|
"authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Schema for AI response analysis
|
||||||
|
const responseAnalysisSchema = z.object({
|
||||||
|
intent: z.enum([
|
||||||
|
"acceptance",
|
||||||
|
"rejection",
|
||||||
|
"counter_offer",
|
||||||
|
"request_info",
|
||||||
|
"unclear",
|
||||||
|
])
|
||||||
|
.describe("The primary intent of the response"),
|
||||||
|
sentiment: z.enum(["positive", "negative", "neutral"])
|
||||||
|
.describe("Overall sentiment of the response"),
|
||||||
|
confidence: z.number().min(0).max(1)
|
||||||
|
.describe("Confidence in the intent classification"),
|
||||||
|
extractedTerms: z.object({
|
||||||
|
proposedAmount: z.number().optional().describe(
|
||||||
|
"Any amount mentioned in response",
|
||||||
|
),
|
||||||
|
proposedPaymentPlan: z.string().optional().describe(
|
||||||
|
"Payment plan details if mentioned",
|
||||||
|
),
|
||||||
|
paymentTerms: z.object({
|
||||||
|
monthlyAmount: z.number().optional().describe("Monthly payment amount"),
|
||||||
|
numberOfPayments: z.number().optional().describe(
|
||||||
|
"Number of payments/installments",
|
||||||
|
),
|
||||||
|
totalAmount: z.number().optional().describe("Total amount to be paid"),
|
||||||
|
interestRate: z.number().optional().describe(
|
||||||
|
"Interest rate if applicable",
|
||||||
|
),
|
||||||
|
paymentFrequency: z.string().optional().describe(
|
||||||
|
"Payment frequency (monthly, weekly, etc.)",
|
||||||
|
),
|
||||||
|
}).optional().describe("Structured payment plan terms"),
|
||||||
|
deadline: z.string().optional().describe("Any deadline mentioned"),
|
||||||
|
conditions: z.array(z.string()).optional().describe(
|
||||||
|
"Any conditions or requirements mentioned",
|
||||||
|
),
|
||||||
|
}).describe("Key terms extracted from the response"),
|
||||||
|
reasoning: z.string().describe("Explanation of the analysis"),
|
||||||
|
suggestedNextAction: z.enum([
|
||||||
|
"accept_offer",
|
||||||
|
"send_counter",
|
||||||
|
"request_clarification",
|
||||||
|
"escalate_to_user",
|
||||||
|
"mark_settled",
|
||||||
|
]).describe("Recommended next action"),
|
||||||
|
requiresUserReview: z.boolean().describe(
|
||||||
|
"Whether this response needs human review",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface EmailResponseData {
|
||||||
|
debtId: string;
|
||||||
|
fromEmail: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
messageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI-powered response analysis
|
||||||
|
async function analyzeEmailResponse(
|
||||||
|
debtId: string,
|
||||||
|
fromEmail: string,
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
originalNegotiation?: any,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
|
||||||
|
if (!googleApiKey) {
|
||||||
|
console.warn("Google API key not configured, using fallback analysis");
|
||||||
|
return getFallbackAnalysis(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Analyzing email with AI:", {
|
||||||
|
fromEmail,
|
||||||
|
subject,
|
||||||
|
bodyLength: body.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
debtId,
|
||||||
|
fromEmail,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
originalNegotiation,
|
||||||
|
});
|
||||||
|
|
||||||
|
const system =
|
||||||
|
`You are an expert financial analysis AI. Your sole function is to meticulously analyze creditor emails and populate a structured JSON object that conforms to the provided schema.
|
||||||
|
|
||||||
|
Your entire output MUST be a single, valid JSON object. Do not include any markdown, explanations, or conversational text outside of the JSON structure itself.
|
||||||
|
|
||||||
|
--- FIELD-BY-FIELD INSTRUCTIONS ---
|
||||||
|
|
||||||
|
1. **intent**: Classify the creditor's primary intent.
|
||||||
|
- "acceptance": Clear agreement to our original proposal.
|
||||||
|
- "rejection": Clear refusal of our proposal without offering new terms.
|
||||||
|
- "counter_offer": Proposes ANY new financial terms (different amount, payment plan, etc.). This is the most common intent besides acceptance.
|
||||||
|
- "request_info": Asks for more information (e.g., "Can you provide proof of hardship?").
|
||||||
|
- "unclear": The purpose of the email cannot be determined.
|
||||||
|
|
||||||
|
2. **sentiment**: Classify the emotional tone of the email.
|
||||||
|
- "positive": Cooperative, polite, agreeable language.
|
||||||
|
- "negative": Hostile, demanding, or threatening language.
|
||||||
|
- "neutral": Strictly professional, factual, and devoid of emotional language.
|
||||||
|
|
||||||
|
3. **confidence**: Provide a score from 0.0 to 1.0 for your "intent" classification. (e.g., 0.95).
|
||||||
|
|
||||||
|
4. **extractedTerms**:
|
||||||
|
- **proposedAmount**: Extract a single, lump-sum settlement amount if offered. If the offer is a payment plan, this field should be null.
|
||||||
|
- **proposedPaymentPlan**: Capture the payment plan offer as a descriptive string, exactly as the creditor states it. e.g., "$100 a month for 12 months". If no plan is mentioned, this is null.
|
||||||
|
- **paymentTerms**: If a payment plan is mentioned, break it down into its structured components here. If no plan is mentioned, this entire object should be null.
|
||||||
|
- **monthlyAmount**: The specific amount for each payment.
|
||||||
|
- **numberOfPayments**: The number of installments.
|
||||||
|
- **totalAmount**: The total payout of the plan, ONLY if explicitly stated (e.g., "...totaling $1200").
|
||||||
|
- **interestRate**: The interest rate as a number (e.g., for "5% interest", extract 5).
|
||||||
|
- **paymentFrequency**: The frequency (e.g., "monthly", "weekly", "bi-weekly").
|
||||||
|
- **deadline**: Extract any specific date or timeframe for action (e.g., "by June 30th", "within 10 days").
|
||||||
|
- **conditions**: Extract all non-financial requirements as an array of strings. Example: ["Payment must be via certified funds", "A settlement agreement must be signed"]. If none, use an empty array [].
|
||||||
|
|
||||||
|
5. **reasoning**: Briefly explain the logic behind your "intent" and "suggestedNextAction" classifications, referencing parts of the email.
|
||||||
|
|
||||||
|
6. **suggestedNextAction**: Recommend the most logical business action.
|
||||||
|
- "accept_offer": The creditor's offer matches or is better than our goal.
|
||||||
|
- "send_counter": The creditor made a counter-offer that we should negotiate.
|
||||||
|
- "request_clarification": The email is ambiguous or missing key information.
|
||||||
|
- "escalate_to_user": The response is hostile, contains legal threats, is complex, or requires a human decision.
|
||||||
|
- "mark_settled": The email confirms the debt is fully settled and no further action is needed.
|
||||||
|
|
||||||
|
7. **requiresUserReview**: Set to 'true' if intent is "unclear", sentiment is "negative", confidence is below 0.85, the email contains unusual legal language, or the "suggestedNextAction" is "escalate_to_user". Otherwise, set to 'false'.`;
|
||||||
|
|
||||||
|
const prompt =
|
||||||
|
`Analyze the following email and extract the financial details and intent, populating the JSON object according to your system instructions.
|
||||||
|
|
||||||
|
--- EMAIL TO ANALYZE ---
|
||||||
|
From: ${fromEmail}
|
||||||
|
Subject: ${subject}
|
||||||
|
Body: ${body}
|
||||||
|
|
||||||
|
${
|
||||||
|
originalNegotiation
|
||||||
|
? `--- ORIGINAL CONTEXT FOR YOUR ANALYSIS ---
|
||||||
|
Our Negotiation Strategy: ${originalNegotiation.strategy}
|
||||||
|
Our Proposed Amount: $${originalNegotiation.proposedAmount || "N/A"}
|
||||||
|
Our Proposed Terms: ${originalNegotiation.terms || "N/A"}
|
||||||
|
Our Reasoning: ${originalNegotiation.reasoning || "N/A"}
|
||||||
|
Our Latest Email Body: ${originalNegotiation.body || "N/A"}
|
||||||
|
`
|
||||||
|
: ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
console.log("AI Analysis System:", system);
|
||||||
|
console.log("AI Analysis Prompt:", prompt);
|
||||||
|
|
||||||
|
const result = await generateObject({
|
||||||
|
model: createGoogleGenerativeAI({
|
||||||
|
apiKey: googleApiKey,
|
||||||
|
})("gemini-2.5-flash-preview-04-17"),
|
||||||
|
system,
|
||||||
|
prompt,
|
||||||
|
schema: responseAnalysisSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("AI Analysis Result:", JSON.stringify(result.object, null, 2));
|
||||||
|
console.log(
|
||||||
|
"Extracted Terms:",
|
||||||
|
JSON.stringify(result.object.extractedTerms, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.object;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("AI response analysis error:", error);
|
||||||
|
console.log("Falling back to regex-based analysis");
|
||||||
|
return getFallbackAnalysis(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate financial outcome when offer is accepted
|
||||||
|
function calculateFinancialOutcome(debt: any, analysis: any): any {
|
||||||
|
try {
|
||||||
|
const originalAmount = debt.amount || 0;
|
||||||
|
let acceptedAmount = originalAmount;
|
||||||
|
let paymentStructure = null;
|
||||||
|
let financialBenefit = null;
|
||||||
|
|
||||||
|
// Try to extract accepted amount from AI analysis
|
||||||
|
if (analysis.extractedTerms?.proposedAmount) {
|
||||||
|
acceptedAmount = analysis.extractedTerms.proposedAmount;
|
||||||
|
} else if (analysis.extractedTerms?.paymentTerms?.totalAmount) {
|
||||||
|
acceptedAmount = analysis.extractedTerms.paymentTerms.totalAmount;
|
||||||
|
} else if (debt.metadata?.prospectedSavings?.amount) {
|
||||||
|
// Fall back to original negotiation terms if no specific amount mentioned
|
||||||
|
acceptedAmount = originalAmount - debt.metadata.prospectedSavings.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze payment structure if present
|
||||||
|
if (analysis.extractedTerms?.paymentTerms) {
|
||||||
|
const terms = analysis.extractedTerms.paymentTerms;
|
||||||
|
paymentStructure = {
|
||||||
|
type: "installment_plan",
|
||||||
|
monthlyAmount: terms.monthlyAmount,
|
||||||
|
numberOfPayments: terms.numberOfPayments,
|
||||||
|
totalAmount: terms.totalAmount || acceptedAmount,
|
||||||
|
frequency: terms.paymentFrequency || "monthly",
|
||||||
|
interestRate: terms.interestRate || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate time value and cash flow benefits
|
||||||
|
if (terms.monthlyAmount && terms.numberOfPayments) {
|
||||||
|
const totalPayments = terms.monthlyAmount * terms.numberOfPayments;
|
||||||
|
const timeToComplete = terms.numberOfPayments; // in months
|
||||||
|
|
||||||
|
financialBenefit = {
|
||||||
|
type: "payment_restructuring",
|
||||||
|
principalReduction: Math.max(0, originalAmount - totalPayments),
|
||||||
|
cashFlowRelief: {
|
||||||
|
monthlyReduction: originalAmount - terms.monthlyAmount,
|
||||||
|
extendedTermMonths: timeToComplete,
|
||||||
|
totalCashFlowBenefit: (originalAmount - terms.monthlyAmount) *
|
||||||
|
timeToComplete,
|
||||||
|
},
|
||||||
|
timeValueBenefit: calculateTimeValueBenefit(
|
||||||
|
originalAmount,
|
||||||
|
terms.monthlyAmount,
|
||||||
|
timeToComplete,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate actual savings (principal reduction)
|
||||||
|
const actualSavings = Math.max(0, originalAmount - acceptedAmount);
|
||||||
|
|
||||||
|
// Determine the primary financial benefit
|
||||||
|
if (actualSavings > 0) {
|
||||||
|
financialBenefit = {
|
||||||
|
type: "principal_reduction",
|
||||||
|
amount: actualSavings,
|
||||||
|
percentage: (actualSavings / originalAmount * 100).toFixed(2),
|
||||||
|
description: `${
|
||||||
|
((actualSavings / originalAmount) * 100).toFixed(1)
|
||||||
|
}% principal reduction`,
|
||||||
|
};
|
||||||
|
} else if (paymentStructure && paymentStructure.monthlyAmount) {
|
||||||
|
// No principal reduction but payment restructuring
|
||||||
|
const monthlyReduction = originalAmount - paymentStructure.monthlyAmount;
|
||||||
|
financialBenefit = {
|
||||||
|
type: "payment_restructuring",
|
||||||
|
monthlyReduction: monthlyReduction,
|
||||||
|
extendedTermMonths: paymentStructure.numberOfPayments,
|
||||||
|
description:
|
||||||
|
`Payment restructured to $${paymentStructure.monthlyAmount}/month over ${paymentStructure.numberOfPayments} months`,
|
||||||
|
cashFlowBenefit: monthlyReduction > 0
|
||||||
|
? `$${monthlyReduction}/month cash flow relief`
|
||||||
|
: "Extended payment terms",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Financial outcome: Original: $${originalAmount}, Accepted: $${acceptedAmount}, Savings: $${actualSavings}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
actualSavings,
|
||||||
|
acceptedAmount,
|
||||||
|
paymentStructure,
|
||||||
|
financialBenefit,
|
||||||
|
originalAmount,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error calculating financial outcome:", error);
|
||||||
|
// Return basic fallback
|
||||||
|
return {
|
||||||
|
actualSavings: debt.projected_savings || 0,
|
||||||
|
acceptedAmount: debt.amount,
|
||||||
|
paymentStructure: null,
|
||||||
|
financialBenefit: null,
|
||||||
|
originalAmount: debt.amount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate time value benefit of extended payment terms
|
||||||
|
function calculateTimeValueBenefit(
|
||||||
|
originalAmount: number,
|
||||||
|
monthlyPayment: number,
|
||||||
|
months: number,
|
||||||
|
): any {
|
||||||
|
// Simple present value calculation assuming 5% annual discount rate
|
||||||
|
const monthlyRate = 0.05 / 12;
|
||||||
|
let presentValue = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i <= months; i++) {
|
||||||
|
presentValue += monthlyPayment / Math.pow(1 + monthlyRate, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeValueBenefit = originalAmount - presentValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
presentValueOfPayments: presentValue.toFixed(2),
|
||||||
|
timeValueBenefit: timeValueBenefit.toFixed(2),
|
||||||
|
effectiveDiscount: ((timeValueBenefit / originalAmount) * 100).toFixed(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback analysis when AI is unavailable
|
||||||
|
function getFallbackAnalysis(
|
||||||
|
body: string,
|
||||||
|
): typeof responseAnalysisSchema._type {
|
||||||
|
const lowerBody = body.toLowerCase();
|
||||||
|
|
||||||
|
// Simple keyword-based analysis
|
||||||
|
let intent:
|
||||||
|
| "acceptance"
|
||||||
|
| "rejection"
|
||||||
|
| "counter_offer"
|
||||||
|
| "request_info"
|
||||||
|
| "unclear" = "unclear";
|
||||||
|
let sentiment: "positive" | "negative" | "neutral" = "neutral";
|
||||||
|
|
||||||
|
if (
|
||||||
|
lowerBody.includes("accept") || lowerBody.includes("agree") ||
|
||||||
|
lowerBody.includes("approved")
|
||||||
|
) {
|
||||||
|
intent = "acceptance";
|
||||||
|
sentiment = "positive";
|
||||||
|
} else if (
|
||||||
|
lowerBody.includes("reject") || lowerBody.includes("decline") ||
|
||||||
|
lowerBody.includes("denied")
|
||||||
|
) {
|
||||||
|
intent = "rejection";
|
||||||
|
sentiment = "negative";
|
||||||
|
} else if (
|
||||||
|
lowerBody.includes("counter") || lowerBody.includes("instead") ||
|
||||||
|
lowerBody.includes("however")
|
||||||
|
) {
|
||||||
|
intent = "counter_offer";
|
||||||
|
sentiment = "neutral";
|
||||||
|
} else if (
|
||||||
|
lowerBody.includes("information") || lowerBody.includes("clarify") ||
|
||||||
|
lowerBody.includes("details")
|
||||||
|
) {
|
||||||
|
intent = "request_info";
|
||||||
|
sentiment = "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced extraction using multiple regex patterns
|
||||||
|
const extractFinancialTerms = (text: string) => {
|
||||||
|
// Extract dollar amounts
|
||||||
|
const amountMatches = text.match(/\$(\d+(?:,\d{3})*(?:\.\d{2})?)/g);
|
||||||
|
const amounts =
|
||||||
|
amountMatches?.map((match) => parseFloat(match.replace(/[$,]/g, ""))) ||
|
||||||
|
[];
|
||||||
|
|
||||||
|
// Extract monthly payment patterns
|
||||||
|
const monthlyMatch = text.match(
|
||||||
|
/\$?(\d+(?:,\d{3})*(?:\.\d{2})?)\s*(?:per month|\/month|monthly)/i,
|
||||||
|
);
|
||||||
|
const monthlyAmount = monthlyMatch
|
||||||
|
? parseFloat(monthlyMatch[1].replace(/,/g, ""))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Extract number of payments/months
|
||||||
|
const paymentsMatch = text.match(
|
||||||
|
/(\d+)\s*(?:months?|payments?|installments?)/i,
|
||||||
|
);
|
||||||
|
const numberOfPayments = paymentsMatch
|
||||||
|
? parseInt(paymentsMatch[1])
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Extract total amount patterns
|
||||||
|
const totalMatch = text.match(
|
||||||
|
/(?:total|totaling|total amount)\s*(?:of\s*)?\$?(\d+(?:,\d{3})*(?:\.\d{2})?)/i,
|
||||||
|
);
|
||||||
|
const totalAmount = totalMatch
|
||||||
|
? parseFloat(totalMatch[1].replace(/,/g, ""))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Determine payment frequency
|
||||||
|
let paymentFrequency = undefined;
|
||||||
|
if (text.match(/monthly|per month|\/month/i)) paymentFrequency = "monthly";
|
||||||
|
else if (text.match(/weekly|per week|\/week/i)) paymentFrequency = "weekly";
|
||||||
|
else if (text.match(/bi-weekly|biweekly|every two weeks/i)) {
|
||||||
|
paymentFrequency = "bi-weekly";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
proposedAmount: amounts.length > 0 ? amounts[0] : undefined,
|
||||||
|
monthlyAmount,
|
||||||
|
numberOfPayments,
|
||||||
|
totalAmount,
|
||||||
|
paymentFrequency,
|
||||||
|
allAmounts: amounts,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const terms = extractFinancialTerms(body);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Fallback Analysis - Extracted Terms:",
|
||||||
|
JSON.stringify(terms, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
intent,
|
||||||
|
sentiment,
|
||||||
|
confidence: 0.6, // Lower confidence for fallback
|
||||||
|
extractedTerms: {
|
||||||
|
proposedAmount: terms.proposedAmount,
|
||||||
|
proposedPaymentPlan: terms.monthlyAmount ? "payment plan" : undefined,
|
||||||
|
paymentTerms: (terms.monthlyAmount || terms.numberOfPayments)
|
||||||
|
? {
|
||||||
|
monthlyAmount: terms.monthlyAmount,
|
||||||
|
numberOfPayments: terms.numberOfPayments,
|
||||||
|
totalAmount: terms.totalAmount,
|
||||||
|
paymentFrequency: terms.paymentFrequency,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
deadline: undefined,
|
||||||
|
conditions: [],
|
||||||
|
},
|
||||||
|
reasoning:
|
||||||
|
`Generated using enhanced keyword-based fallback analysis. Found ${terms.allAmounts.length} amounts.`,
|
||||||
|
suggestedNextAction: intent === "acceptance"
|
||||||
|
? "mark_settled"
|
||||||
|
: intent === "rejection"
|
||||||
|
? "escalate_to_user"
|
||||||
|
: intent === "counter_offer"
|
||||||
|
? "send_counter"
|
||||||
|
: "escalate_to_user",
|
||||||
|
requiresUserReview: true, // Always require review for fallback
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Fallback Analysis Result:", JSON.stringify(result, null, 2));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
serve(async (req) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabaseClient = createClient(
|
||||||
|
Deno.env.get("SUPABASE_URL") ?? "",
|
||||||
|
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
|
||||||
|
{
|
||||||
|
global: {
|
||||||
|
headers: { Authorization: req.headers.get("Authorization")! },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { debtId, fromEmail, subject, body, messageId }: EmailResponseData =
|
||||||
|
await req.json();
|
||||||
|
|
||||||
|
if (!debtId || !fromEmail || !body) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Missing required fields: debtId, fromEmail, body",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the debt record and original negotiation context
|
||||||
|
const { data: debt, error: debtError } = await supabaseClient
|
||||||
|
.from("debts")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", debtId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (debtError || !debt) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Debt record not found" }),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze the response using AI
|
||||||
|
const analysis = await analyzeEmailResponse(
|
||||||
|
debtId,
|
||||||
|
fromEmail,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
debt.metadata?.aiEmail,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the conversation message
|
||||||
|
const { error: messageError } = await supabaseClient
|
||||||
|
.from("conversation_messages")
|
||||||
|
.update({
|
||||||
|
debt_id: debtId,
|
||||||
|
message_type: analysis.intent === "acceptance"
|
||||||
|
? "acceptance"
|
||||||
|
: analysis.intent === "rejection"
|
||||||
|
? "rejection"
|
||||||
|
: analysis.intent === "counter_offer"
|
||||||
|
? "counter_offer"
|
||||||
|
: "response_received",
|
||||||
|
direction: "inbound",
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
from_email: fromEmail,
|
||||||
|
to_email: debt.metadata?.toEmail || debt.metadata?.fromEmail,
|
||||||
|
ai_analysis: analysis,
|
||||||
|
})
|
||||||
|
.eq("message_id", messageId);
|
||||||
|
|
||||||
|
if (messageError) {
|
||||||
|
console.error("Error storing conversation message:", messageError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine next status and actions based on analysis
|
||||||
|
let newStatus = debt.status;
|
||||||
|
let newNegotiationRound = debt.negotiation_round || 1;
|
||||||
|
let financialOutcome = null;
|
||||||
|
let shouldAutoRespond = false;
|
||||||
|
let nextAction = null;
|
||||||
|
|
||||||
|
switch (analysis.intent) {
|
||||||
|
case "acceptance":
|
||||||
|
newStatus = "accepted";
|
||||||
|
nextAction = "mark_settled";
|
||||||
|
// Calculate financial outcome when offer is accepted
|
||||||
|
financialOutcome = calculateFinancialOutcome(debt, analysis);
|
||||||
|
break;
|
||||||
|
case "rejection":
|
||||||
|
newStatus = "rejected";
|
||||||
|
nextAction = "escalate_to_user";
|
||||||
|
break;
|
||||||
|
case "counter_offer":
|
||||||
|
newStatus = "counter_negotiating";
|
||||||
|
newNegotiationRound += 1;
|
||||||
|
shouldAutoRespond = !analysis.requiresUserReview &&
|
||||||
|
analysis.confidence > 0.8;
|
||||||
|
nextAction = analysis.suggestedNextAction;
|
||||||
|
break;
|
||||||
|
case "request_info":
|
||||||
|
newStatus = "awaiting_response";
|
||||||
|
nextAction = "escalate_to_user";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
newStatus = "awaiting_response";
|
||||||
|
nextAction = "escalate_to_user";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update debt record
|
||||||
|
const updateData: any = {
|
||||||
|
status: newStatus,
|
||||||
|
negotiation_round: newNegotiationRound,
|
||||||
|
conversation_count: (debt.conversation_count || 0) + 1,
|
||||||
|
last_message_at: new Date().toISOString(),
|
||||||
|
metadata: {
|
||||||
|
...debt.metadata,
|
||||||
|
lastResponse: {
|
||||||
|
analysis,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
fromEmail,
|
||||||
|
subject,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add financial outcome if offer was accepted
|
||||||
|
if (analysis.intent === "acceptance" && financialOutcome) {
|
||||||
|
updateData.actual_savings = financialOutcome.actualSavings;
|
||||||
|
updateData.status = "settled"; // Set final status here instead of separate update
|
||||||
|
updateData.metadata.financialOutcome = {
|
||||||
|
...financialOutcome,
|
||||||
|
calculatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep backward compatibility with actualSavings field
|
||||||
|
updateData.metadata.actualSavings = {
|
||||||
|
amount: financialOutcome.actualSavings,
|
||||||
|
calculatedAt: new Date().toISOString(),
|
||||||
|
originalAmount: financialOutcome.originalAmount,
|
||||||
|
acceptedAmount: financialOutcome.acceptedAmount,
|
||||||
|
savingsPercentage: financialOutcome.originalAmount > 0
|
||||||
|
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
|
||||||
|
100).toFixed(2)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabaseClient
|
||||||
|
.from("debts")
|
||||||
|
.update(updateData)
|
||||||
|
.eq("id", debtId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error("Error updating debt:", updateError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the action
|
||||||
|
const auditDetails: any = {
|
||||||
|
intent: analysis.intent,
|
||||||
|
sentiment: analysis.sentiment,
|
||||||
|
confidence: analysis.confidence,
|
||||||
|
fromEmail,
|
||||||
|
subject,
|
||||||
|
suggestedAction: analysis.suggestedNextAction,
|
||||||
|
requiresReview: analysis.requiresUserReview,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add financial outcome to audit log if offer was accepted
|
||||||
|
if (analysis.intent === "acceptance" && financialOutcome) {
|
||||||
|
auditDetails.financialOutcome = financialOutcome;
|
||||||
|
auditDetails.actualSavings = financialOutcome.actualSavings;
|
||||||
|
auditDetails.originalAmount = financialOutcome.originalAmount;
|
||||||
|
auditDetails.acceptedAmount = financialOutcome.acceptedAmount;
|
||||||
|
auditDetails.savingsPercentage = financialOutcome.originalAmount > 0
|
||||||
|
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
|
||||||
|
100).toFixed(2)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabaseClient
|
||||||
|
.from("audit_logs")
|
||||||
|
.insert({
|
||||||
|
debt_id: debtId,
|
||||||
|
action: analysis.intent === "acceptance"
|
||||||
|
? "offer_accepted"
|
||||||
|
: "response_analyzed",
|
||||||
|
details: {
|
||||||
|
...auditDetails,
|
||||||
|
nextAction,
|
||||||
|
shouldAutoRespond,
|
||||||
|
negotiationRound: newNegotiationRound,
|
||||||
|
reasoning: analysis.reasoning,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If this is an acceptance, mark as settled
|
||||||
|
if (analysis.intent === "acceptance") {
|
||||||
|
// await supabaseClient
|
||||||
|
// .from("debts")
|
||||||
|
// .update({ status: "settled" })
|
||||||
|
// .eq("id", debtId);
|
||||||
|
|
||||||
|
await supabaseClient.from("audit_logs").insert({
|
||||||
|
debt_id: debtId,
|
||||||
|
action: "debt_settled",
|
||||||
|
details: {
|
||||||
|
finalAmount: financialOutcome?.acceptedAmount || debt.amount,
|
||||||
|
actualSavings: financialOutcome?.actualSavings || 0,
|
||||||
|
settlementTerms: analysis.extractedTerms,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If auto-response is recommended and confidence is high, trigger negotiation
|
||||||
|
if (
|
||||||
|
shouldAutoRespond && analysis.confidence > 0.8 &&
|
||||||
|
analysis.intent === "counter_offer"
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const negotiateUrl = `${
|
||||||
|
Deno.env.get("SUPABASE_URL")
|
||||||
|
}/functions/v1/negotiate`;
|
||||||
|
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||||
|
|
||||||
|
if (negotiateUrl && serviceKey) {
|
||||||
|
await fetch(negotiateUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${serviceKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
record: {
|
||||||
|
...debt,
|
||||||
|
status: newStatus,
|
||||||
|
conversation_count: (debt.conversation_count || 0) + 1,
|
||||||
|
negotiation_round: newNegotiationRound,
|
||||||
|
},
|
||||||
|
counterOfferContext: {
|
||||||
|
previousResponse: body,
|
||||||
|
extractedTerms: analysis.extractedTerms,
|
||||||
|
sentiment: analysis.sentiment,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabaseClient.from("audit_logs").insert({
|
||||||
|
debt_id: debtId,
|
||||||
|
action: "auto_counter_triggered",
|
||||||
|
details: {
|
||||||
|
confidence: analysis.confidence,
|
||||||
|
extractedTerms: analysis.extractedTerms,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (autoResponseError) {
|
||||||
|
console.error("Error triggering auto-response:", autoResponseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData: any = {
|
||||||
|
success: true,
|
||||||
|
analysis,
|
||||||
|
newStatus,
|
||||||
|
negotiationRound: newNegotiationRound,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include financial outcome in response if offer was accepted
|
||||||
|
if (analysis.intent === "acceptance" && financialOutcome) {
|
||||||
|
responseData.financialOutcome = financialOutcome;
|
||||||
|
responseData.actualSavings = financialOutcome.actualSavings;
|
||||||
|
responseData.savingsCalculated = true;
|
||||||
|
responseData.originalAmount = financialOutcome.originalAmount;
|
||||||
|
responseData.acceptedAmount = financialOutcome.acceptedAmount;
|
||||||
|
responseData.savingsPercentage = financialOutcome.originalAmount > 0
|
||||||
|
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
|
||||||
|
100).toFixed(2)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify(responseData),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in analyze-response function:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Internal server error" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -31,11 +31,14 @@ interface DebtRecord {
|
|||||||
amount: number;
|
amount: number;
|
||||||
raw_email: string;
|
raw_email: string;
|
||||||
negotiated_plan?: string;
|
negotiated_plan?: string;
|
||||||
|
projected_savings?: number;
|
||||||
|
conversation_count?: number;
|
||||||
metadata?: {
|
metadata?: {
|
||||||
aiEmail?: {
|
aiEmail?: {
|
||||||
subject: string;
|
subject: string;
|
||||||
body: string;
|
body: string;
|
||||||
strategy: string;
|
strategy: string;
|
||||||
|
confidence?: number;
|
||||||
};
|
};
|
||||||
toEmail?: string;
|
toEmail?: string;
|
||||||
fromEmail?: string;
|
fromEmail?: string;
|
||||||
@@ -243,11 +246,20 @@ Deno.serve(async (req) => {
|
|||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update debt status to sent - using authenticated client
|
// Update debt status to sent and preserve negotiating state
|
||||||
|
const { data: currentDebt } = await supabaseClient
|
||||||
|
.from("debts")
|
||||||
|
.select("conversation_count, negotiation_round")
|
||||||
|
.eq("id", debtId)
|
||||||
|
.single();
|
||||||
|
|
||||||
const { error: updateError } = await supabaseClient
|
const { error: updateError } = await supabaseClient
|
||||||
.from("debts")
|
.from("debts")
|
||||||
.update({
|
.update({
|
||||||
status: "sent",
|
status: "sent",
|
||||||
|
conversation_count: (currentDebt?.conversation_count || 0) + 1,
|
||||||
|
last_message_at: new Date().toISOString(),
|
||||||
|
prospected_savings: debt.projected_savings || 0, // Store prospected savings when sent
|
||||||
metadata: {
|
metadata: {
|
||||||
...debt.metadata,
|
...debt.metadata,
|
||||||
emailSent: {
|
emailSent: {
|
||||||
@@ -257,6 +269,14 @@ Deno.serve(async (req) => {
|
|||||||
from: fromEmail,
|
from: fromEmail,
|
||||||
subject: subject,
|
subject: subject,
|
||||||
},
|
},
|
||||||
|
prospectedSavings: {
|
||||||
|
amount: debt.projected_savings || 0,
|
||||||
|
percentage: debt.amount > 0
|
||||||
|
? ((debt.projected_savings || 0) / debt.amount * 100).toFixed(2)
|
||||||
|
: 0,
|
||||||
|
calculatedAt: new Date().toISOString(),
|
||||||
|
strategy: debt.metadata?.aiEmail?.strategy || "unknown",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.eq("id", debtId);
|
.eq("id", debtId);
|
||||||
@@ -265,20 +285,35 @@ Deno.serve(async (req) => {
|
|||||||
console.error("Error updating debt status:", updateError);
|
console.error("Error updating debt status:", updateError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the action - using authenticated client
|
// Record the sent email in conversation history
|
||||||
await supabaseClient
|
await supabaseClient.from("conversation_messages").insert({
|
||||||
.from("audit_logs")
|
debt_id: debtId,
|
||||||
.insert({
|
message_type: "negotiation_sent",
|
||||||
debt_id: debtId,
|
direction: "outbound",
|
||||||
action: "email_sent",
|
subject: subject,
|
||||||
details: {
|
body: body,
|
||||||
messageId: emailResult.MessageID,
|
from_email: fromEmail,
|
||||||
to: toEmail,
|
to_email: toEmail,
|
||||||
from: fromEmail,
|
message_id: emailResult.MessageID,
|
||||||
subject: subject,
|
ai_analysis: {
|
||||||
strategy: debt.metadata.aiEmail.strategy,
|
strategy: debt.metadata.aiEmail.strategy,
|
||||||
},
|
confidence: debt.metadata.aiEmail.confidence,
|
||||||
});
|
projectedSavings: debt.projected_savings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the email sending
|
||||||
|
await supabaseClient.from("audit_logs").insert({
|
||||||
|
debt_id: debtId,
|
||||||
|
action: "email_sent",
|
||||||
|
details: {
|
||||||
|
to: toEmail,
|
||||||
|
subject: subject,
|
||||||
|
postmarkMessageId: emailResult.MessageID,
|
||||||
|
conversationRound: currentDebt?.negotiation_round || 1,
|
||||||
|
strategy: debt.metadata.aiEmail.strategy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
170
supabase/functions/test-extraction/index.ts
Normal file
170
supabase/functions/test-extraction/index.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||||
|
import { generateObject } from "https://esm.sh/ai@3.4.7";
|
||||||
|
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
|
||||||
|
import { z } from "https://esm.sh/zod@3.22.4";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers":
|
||||||
|
"authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same schema as analyze-response
|
||||||
|
const responseAnalysisSchema = z.object({
|
||||||
|
intent: z.enum([
|
||||||
|
"acceptance",
|
||||||
|
"rejection",
|
||||||
|
"counter_offer",
|
||||||
|
"request_info",
|
||||||
|
"unclear",
|
||||||
|
]).describe("The primary intent of the response"),
|
||||||
|
sentiment: z.enum(["positive", "negative", "neutral"])
|
||||||
|
.describe("Overall sentiment of the response"),
|
||||||
|
confidence: z.number().min(0).max(1)
|
||||||
|
.describe("Confidence in the intent classification"),
|
||||||
|
extractedTerms: z.object({
|
||||||
|
proposedAmount: z.number().optional().describe(
|
||||||
|
"Any amount mentioned in response",
|
||||||
|
),
|
||||||
|
proposedPaymentPlan: z.string().optional().describe(
|
||||||
|
"Payment plan details if mentioned",
|
||||||
|
),
|
||||||
|
paymentTerms: z.object({
|
||||||
|
monthlyAmount: z.number().optional().describe("Monthly payment amount"),
|
||||||
|
numberOfPayments: z.number().optional().describe(
|
||||||
|
"Number of payments/installments",
|
||||||
|
),
|
||||||
|
totalAmount: z.number().optional().describe("Total amount to be paid"),
|
||||||
|
interestRate: z.number().optional().describe(
|
||||||
|
"Interest rate if applicable",
|
||||||
|
),
|
||||||
|
paymentFrequency: z.string().optional().describe(
|
||||||
|
"Payment frequency (monthly, weekly, etc.)",
|
||||||
|
),
|
||||||
|
}).optional().describe("Structured payment plan terms"),
|
||||||
|
deadline: z.string().optional().describe("Any deadline mentioned"),
|
||||||
|
conditions: z.array(z.string()).optional().describe(
|
||||||
|
"Any conditions or requirements mentioned",
|
||||||
|
),
|
||||||
|
}).describe("Key terms extracted from the response"),
|
||||||
|
reasoning: z.string().describe("Explanation of the analysis"),
|
||||||
|
suggestedNextAction: z.enum([
|
||||||
|
"accept_offer",
|
||||||
|
"send_counter",
|
||||||
|
"request_clarification",
|
||||||
|
"escalate_to_user",
|
||||||
|
"mark_settled",
|
||||||
|
]).describe("Recommended next action"),
|
||||||
|
requiresUserReview: z.boolean().describe(
|
||||||
|
"Whether this response needs human review",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
serve(async (req) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { testEmail } = await req.json();
|
||||||
|
|
||||||
|
if (!testEmail) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "testEmail is required" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
|
||||||
|
|
||||||
|
if (!googleApiKey) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Google API key not configured" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Testing extraction with email:", testEmail);
|
||||||
|
|
||||||
|
const result = await generateObject({
|
||||||
|
model: createGoogleGenerativeAI({
|
||||||
|
apiKey: googleApiKey,
|
||||||
|
})("gemini-2.5-flash-preview-04-17"),
|
||||||
|
system: `You are an expert financial analyst specializing in debt collection and negotiation responses.
|
||||||
|
Your job is to carefully analyze creditor responses and extract ALL financial terms mentioned.
|
||||||
|
|
||||||
|
CRITICAL: Always extract financial information when present. Look for:
|
||||||
|
|
||||||
|
AMOUNTS:
|
||||||
|
- Any dollar amounts mentioned ($1,000, $500, etc.)
|
||||||
|
- Settlement offers or counter-offers
|
||||||
|
- Monthly payment amounts
|
||||||
|
- Total payment amounts
|
||||||
|
|
||||||
|
PAYMENT PLANS:
|
||||||
|
- Monthly payment amounts (e.g., "$200 per month", "$150/month")
|
||||||
|
- Number of payments/installments (e.g., "12 months", "24 payments")
|
||||||
|
- Payment frequency (monthly, weekly, bi-weekly)
|
||||||
|
- Total amounts for payment plans
|
||||||
|
- Interest rates if mentioned
|
||||||
|
|
||||||
|
EXTRACT EVERYTHING: Even if amounts seem obvious, always include them in extractedTerms.`,
|
||||||
|
prompt: `Analyze this test email and extract ALL financial terms:
|
||||||
|
|
||||||
|
EMAIL: ${testEmail}
|
||||||
|
|
||||||
|
EXTRACTION REQUIREMENTS:
|
||||||
|
1. Find ANY dollar amounts mentioned in the email
|
||||||
|
2. Look for payment plan details (monthly amounts, number of payments)
|
||||||
|
3. Identify payment frequency (monthly, weekly, etc.)
|
||||||
|
4. Extract total amounts if mentioned
|
||||||
|
5. Note any interest rates or fees
|
||||||
|
6. Capture all conditions and requirements
|
||||||
|
|
||||||
|
EXAMPLES OF WHAT TO EXTRACT:
|
||||||
|
- "We can accept $250 per month" → monthlyAmount: 250
|
||||||
|
- "for 18 months" → numberOfPayments: 18
|
||||||
|
- "totaling $4,500" → totalAmount: 4500
|
||||||
|
- "settlement of $3,200" → proposedAmount: 3200
|
||||||
|
- "monthly payments" → paymentFrequency: "monthly"
|
||||||
|
|
||||||
|
Be thorough and extract ALL financial information present in the email.`,
|
||||||
|
schema: responseAnalysisSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("AI Analysis Result:", JSON.stringify(result.object, null, 2));
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
analysis: result.object,
|
||||||
|
extractedTerms: result.object.extractedTerms,
|
||||||
|
debug: {
|
||||||
|
emailLength: testEmail.length,
|
||||||
|
hasGoogleAPI: !!googleApiKey,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in test-extraction function:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Internal server error",
|
||||||
|
details: error.message
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
84
supabase/migrations/20250607012000_conversation_tracking.sql
Normal file
84
supabase/migrations/20250607012000_conversation_tracking.sql
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
-- Enhanced conversation tracking and negotiation flow
|
||||||
|
-- This migration adds comprehensive conversation tracking and improved status management
|
||||||
|
|
||||||
|
-- Create conversation_messages table to track all email exchanges
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_messages (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
debt_id uuid REFERENCES debts(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
message_type text NOT NULL CHECK (message_type IN ('initial_debt', 'negotiation_sent', 'response_received', 'counter_offer', 'acceptance', 'rejection')),
|
||||||
|
direction text NOT NULL CHECK (direction IN ('inbound', 'outbound')),
|
||||||
|
subject text,
|
||||||
|
body text NOT NULL,
|
||||||
|
from_email text,
|
||||||
|
to_email text,
|
||||||
|
message_id text, -- Postmark message ID for outbound, email ID for inbound
|
||||||
|
ai_analysis jsonb DEFAULT '{}'::jsonb, -- AI analysis of the message content
|
||||||
|
created_at timestamptz DEFAULT now(),
|
||||||
|
updated_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversation_messages_debt_id ON conversation_messages(debt_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversation_messages_created_at ON conversation_messages(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversation_messages_type ON conversation_messages(message_type);
|
||||||
|
|
||||||
|
-- Update debts table status constraint to include new statuses
|
||||||
|
ALTER TABLE debts
|
||||||
|
DROP CONSTRAINT IF EXISTS debts_status_check;
|
||||||
|
|
||||||
|
ALTER TABLE debts
|
||||||
|
ADD CONSTRAINT debts_status_check
|
||||||
|
CHECK (status IN (
|
||||||
|
'received',
|
||||||
|
'negotiating',
|
||||||
|
'approved',
|
||||||
|
'sent',
|
||||||
|
'awaiting_response',
|
||||||
|
'counter_negotiating',
|
||||||
|
'accepted',
|
||||||
|
'rejected',
|
||||||
|
'settled',
|
||||||
|
'failed',
|
||||||
|
'opted_out'
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Add conversation tracking fields to debts table
|
||||||
|
ALTER TABLE debts ADD COLUMN IF NOT EXISTS conversation_count integer DEFAULT 0;
|
||||||
|
ALTER TABLE debts ADD COLUMN IF NOT EXISTS last_message_at timestamptz DEFAULT now();
|
||||||
|
ALTER TABLE debts ADD COLUMN IF NOT EXISTS negotiation_round integer DEFAULT 1;
|
||||||
|
ALTER TABLE debts ADD COLUMN IF NOT EXISTS prospected_savings numeric DEFAULT 0;
|
||||||
|
ALTER TABLE debts ADD COLUMN IF NOT EXISTS actual_savings numeric DEFAULT 0;
|
||||||
|
|
||||||
|
-- Create indexes for new columns
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_debts_last_message_at ON debts(last_message_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_debts_negotiation_round ON debts(negotiation_round);
|
||||||
|
|
||||||
|
-- Enable RLS on conversation_messages
|
||||||
|
ALTER TABLE conversation_messages ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Add RLS policy for conversation_messages (users can only see their own debt conversations)
|
||||||
|
CREATE POLICY "Users can view their own conversation messages" ON conversation_messages
|
||||||
|
FOR SELECT USING (
|
||||||
|
debt_id IN (
|
||||||
|
SELECT id FROM debts WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can insert their own conversation messages" ON conversation_messages
|
||||||
|
FOR INSERT WITH CHECK (
|
||||||
|
debt_id IN (
|
||||||
|
SELECT id FROM debts WHERE user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enable real-time for conversation_messages
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE conversation_messages;
|
||||||
|
|
||||||
|
-- Add comments for documentation
|
||||||
|
COMMENT ON TABLE conversation_messages IS 'Tracks all email exchanges in debt negotiations';
|
||||||
|
COMMENT ON COLUMN conversation_messages.message_type IS 'Type of message in the negotiation flow';
|
||||||
|
COMMENT ON COLUMN conversation_messages.direction IS 'Whether message was sent (outbound) or received (inbound)';
|
||||||
|
COMMENT ON COLUMN conversation_messages.ai_analysis IS 'AI analysis results including intent, sentiment, and extracted terms';
|
||||||
|
COMMENT ON COLUMN debts.conversation_count IS 'Total number of messages in this debt conversation';
|
||||||
|
COMMENT ON COLUMN debts.last_message_at IS 'Timestamp of the most recent message in conversation';
|
||||||
|
COMMENT ON COLUMN debts.negotiation_round IS 'Current round of negotiation (increments with each back-and-forth)';
|
||||||
Reference in New Issue
Block a user