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.",
|
||||
"FromFull": {
|
||||
"Email": "billing@creditplus.com",
|
||||
"Email": "contact@francisco-pessano.com",
|
||||
"Name": "CreditPlus Billing Department"
|
||||
},
|
||||
"ToFull": [
|
||||
{
|
||||
"Email": "franpessano1@gmail.com",
|
||||
"Email": "contacto@francisco-pessano.com",
|
||||
"Name": "",
|
||||
"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 { Button } from "./ui/button";
|
||||
import { DebtCard } from "./DebtCard";
|
||||
import { DebtTimeline } from "./DebtTimeline";
|
||||
import { ConversationTimeline } from "./ConversationTimeline";
|
||||
import { OnboardingDialog } from "./OnboardingDialog";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Separator } from "./ui/separator";
|
||||
@@ -21,10 +15,9 @@ import {
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
LogOut,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { formatCurrency } from "../lib/utils";
|
||||
|
||||
export function Dashboard() {
|
||||
const [debts, setDebts] = useState<Debt[]>([]);
|
||||
@@ -130,10 +123,13 @@ export function Dashboard() {
|
||||
const calculateStats = () => {
|
||||
const totalDebts = debts.length;
|
||||
const totalAmount = debts.reduce((sum, debt) => sum + debt.amount, 0);
|
||||
const projectedSavings = debts.reduce(
|
||||
(sum, debt) => sum + debt.projected_savings,
|
||||
0
|
||||
);
|
||||
const projectedSavings = debts.reduce((sum, debt) => {
|
||||
// Use actual savings for accepted debts, projected for others
|
||||
if (debt.status === "accepted" && debt.metadata?.actualSavings?.amount) {
|
||||
return sum + debt.metadata.actualSavings.amount;
|
||||
}
|
||||
return sum + debt.projected_savings;
|
||||
}, 0);
|
||||
const settledCount = debts.filter(
|
||||
(debt) => debt.status === "settled"
|
||||
).length;
|
||||
@@ -157,23 +153,22 @@ export function Dashboard() {
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const groupedDebts = {
|
||||
all: debts,
|
||||
active: debts.filter((debt) =>
|
||||
["received", "negotiating"].includes(debt.status)
|
||||
[
|
||||
"received",
|
||||
"negotiating",
|
||||
"approved",
|
||||
"awaiting_response",
|
||||
"counter_negotiating",
|
||||
].includes(debt.status)
|
||||
),
|
||||
settled: debts.filter((debt) =>
|
||||
["settled", "approved", "sent"].includes(debt.status)
|
||||
["settled", "accepted", "sent"].includes(debt.status)
|
||||
),
|
||||
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) => (
|
||||
<div key={debt.id} className="space-y-4">
|
||||
<DebtCard debt={debt} onUpdate={fetchDebts} />
|
||||
<Card className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<CardContent className="p-4">
|
||||
<DebtTimeline debt={debt} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ConversationTimeline
|
||||
debt={debt}
|
||||
onDebtUpdate={(debt) => {
|
||||
setDebts(
|
||||
debts.map((d) => (d.id === debt.id ? debt : d))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Textarea } from "./ui/textarea";
|
||||
import { Label } from "./ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { supabase, type Debt, type DebtVariable } from "../lib/supabase";
|
||||
import { toast } from "sonner";
|
||||
import { formatCurrency } from "../lib/utils";
|
||||
|
||||
interface DebtCardProps {
|
||||
debt: Debt;
|
||||
@@ -59,6 +61,14 @@ const statusColors = {
|
||||
approved:
|
||||
"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",
|
||||
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:
|
||||
"bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800",
|
||||
failed:
|
||||
@@ -72,6 +82,10 @@ const statusLabels = {
|
||||
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",
|
||||
@@ -81,21 +95,20 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
||||
const [isApproving, setIsApproving] = useState(false);
|
||||
const [isRejecting, setIsRejecting] = useState(false);
|
||||
const [userProfile, setUserProfile] = useState<any>(null);
|
||||
const [hasServerToken, setHasServerToken] = useState(false);
|
||||
const [hasServerToken, setHasServerToken] = useState<boolean | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const isReadOnly =
|
||||
debt.status === "approved" ||
|
||||
debt.status === "sent" ||
|
||||
debt.status === "awaiting_response" ||
|
||||
debt.status === "accepted" ||
|
||||
debt.status === "rejected" ||
|
||||
debt.status === "settled" ||
|
||||
debt.status === "failed" ||
|
||||
debt.status === "opted_out";
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
@@ -466,9 +479,11 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
||||
{/* Action Buttons */}
|
||||
{!isReadOnly && (
|
||||
<div className="flex justify-end gap-2 border-t pt-4">
|
||||
<Button variant="outline" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<DialogClose>
|
||||
<Button variant="outline" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
@@ -663,8 +678,8 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
<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">
|
||||
{formatCurrency(debt.amount)}
|
||||
</span>
|
||||
@@ -710,7 +725,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
||||
{/* Approve/Reject Buttons */}
|
||||
{showApproveRejectButtons() && (
|
||||
<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">
|
||||
<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">
|
||||
@@ -721,7 +736,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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" />
|
||||
Settings
|
||||
@@ -757,7 +772,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleApprove()}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
Send Email
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -1,26 +1,83 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
StopCircle,
|
||||
Send,
|
||||
MessageSquare,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { Debt } from "../lib/supabase";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
interface DebtTimelineProps {
|
||||
debt: Debt;
|
||||
}
|
||||
|
||||
const timelineSteps = [
|
||||
{ key: "received", label: "Email Received", icon: CheckCircle },
|
||||
{ key: "negotiating", label: "Negotiating", icon: Clock },
|
||||
{ key: "settled", label: "Settled", icon: CheckCircle },
|
||||
];
|
||||
interface ConversationMessage {
|
||||
id: string;
|
||||
message_type: string;
|
||||
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 = {
|
||||
received: CheckCircle,
|
||||
received: MessageSquare,
|
||||
negotiating: Clock,
|
||||
approved: CheckCircle,
|
||||
sent: Send,
|
||||
awaiting_response: Clock,
|
||||
counter_negotiating: RefreshCw,
|
||||
accepted: ThumbsUp,
|
||||
rejected: ThumbsDown,
|
||||
settled: CheckCircle,
|
||||
failed: XCircle,
|
||||
opted_out: StopCircle,
|
||||
@@ -29,20 +86,139 @@ const statusIcons = {
|
||||
const statusColors = {
|
||||
received: "text-blue-600 dark:text-blue-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",
|
||||
failed: "text-red-600 dark:text-red-400",
|
||||
opted_out: "text-gray-600 dark:text-gray-400",
|
||||
};
|
||||
|
||||
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(
|
||||
(step) => step.key === debt.status
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Progress Timeline</h3>
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
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">
|
||||
{timelineSteps.map((step, index) => {
|
||||
const isCompleted = index <= currentStepIndex;
|
||||
@@ -50,90 +226,138 @@ export function DebtTimeline({ debt }: DebtTimelineProps) {
|
||||
const Icon = step.icon;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-3">
|
||||
<div key={step.key} className="flex items-start gap-3">
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-center w-8 h-8 rounded-full border-2
|
||||
${
|
||||
isCompleted
|
||||
? "bg-primary border-primary text-white"
|
||||
: "border-gray-300 dark:border-gray-600 text-gray-300 dark:text-gray-600"
|
||||
}
|
||||
${isActive ? "ring-2 ring-primary/20" : ""}
|
||||
`}
|
||||
flex items-center justify-center w-8 h-8 rounded-full border-2 mt-1
|
||||
${
|
||||
isCompleted
|
||||
? "bg-primary border-primary text-white"
|
||||
: "border-gray-300 dark:border-gray-600 text-gray-300 dark:text-gray-600"
|
||||
}
|
||||
${isActive ? "ring-2 ring-primary/20" : ""}
|
||||
`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={`
|
||||
font-medium
|
||||
${
|
||||
isCompleted
|
||||
? "text-gray-900 dark:text-foreground"
|
||||
: "text-gray-400 dark:text-gray-500"
|
||||
}
|
||||
`}
|
||||
font-medium
|
||||
${
|
||||
isCompleted
|
||||
? "text-gray-900 dark:text-foreground"
|
||||
: "text-gray-400 dark:text-gray-500"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{step.label}
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-1">
|
||||
{debt.status === "received" &&
|
||||
"Processing email and generating response..."}
|
||||
{debt.status === "negotiating" &&
|
||||
"Response generated, waiting for creditor reply"}
|
||||
{debt.status === "settled" && "Payment plan agreed upon"}
|
||||
{getStatusDescription(debt.status, debt.negotiation_round)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(debt.updated_at).toLocaleDateString()}
|
||||
{isCompleted && debt.updated_at && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||
<div>{formatDateTime(debt.updated_at).date}</div>
|
||||
<div>{formatDateTime(debt.updated_at).time}</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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
| "approved"
|
||||
| "sent"
|
||||
| "awaiting_response"
|
||||
| "counter_negotiating"
|
||||
| "accepted"
|
||||
| "rejected"
|
||||
| "settled"
|
||||
| "failed"
|
||||
| "opted_out";
|
||||
@@ -35,6 +39,11 @@ export type Debt = {
|
||||
user_id: string;
|
||||
description?: 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;
|
||||
};
|
||||
|
||||
@@ -84,3 +93,24 @@ export type DebtVariable = {
|
||||
created_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 { twMerge } from 'tailwind-merge';
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
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 }) => {
|
||||
try {
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// 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
|
||||
await incrementEmailUsage(userId, supabaseAdmin);
|
||||
|
||||
@@ -248,6 +428,9 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
status: "received",
|
||||
description: debtInfo.description,
|
||||
due_date: debtInfo.dueDate,
|
||||
conversation_count: 1,
|
||||
last_message_at: new Date().toISOString(),
|
||||
negotiation_round: 1,
|
||||
metadata: {
|
||||
isDebtCollection: debtInfo.isDebtCollection,
|
||||
subject: data.Subject,
|
||||
@@ -258,6 +441,20 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
.select()
|
||||
.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) {
|
||||
console.error("Error inserting debt:", 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;
|
||||
raw_email: string;
|
||||
negotiated_plan?: string;
|
||||
projected_savings?: number;
|
||||
conversation_count?: number;
|
||||
metadata?: {
|
||||
aiEmail?: {
|
||||
subject: string;
|
||||
body: string;
|
||||
strategy: string;
|
||||
confidence?: number;
|
||||
};
|
||||
toEmail?: string;
|
||||
fromEmail?: string;
|
||||
@@ -243,11 +246,20 @@ Deno.serve(async (req) => {
|
||||
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
|
||||
.from("debts")
|
||||
.update({
|
||||
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: {
|
||||
...debt.metadata,
|
||||
emailSent: {
|
||||
@@ -257,6 +269,14 @@ Deno.serve(async (req) => {
|
||||
from: fromEmail,
|
||||
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);
|
||||
@@ -265,20 +285,35 @@ Deno.serve(async (req) => {
|
||||
console.error("Error updating debt status:", updateError);
|
||||
}
|
||||
|
||||
// Log the action - using authenticated client
|
||||
await supabaseClient
|
||||
.from("audit_logs")
|
||||
.insert({
|
||||
debt_id: debtId,
|
||||
action: "email_sent",
|
||||
details: {
|
||||
messageId: emailResult.MessageID,
|
||||
to: toEmail,
|
||||
from: fromEmail,
|
||||
subject: subject,
|
||||
strategy: debt.metadata.aiEmail.strategy,
|
||||
},
|
||||
});
|
||||
// Record the sent email in conversation history
|
||||
await supabaseClient.from("conversation_messages").insert({
|
||||
debt_id: debtId,
|
||||
message_type: "negotiation_sent",
|
||||
direction: "outbound",
|
||||
subject: subject,
|
||||
body: body,
|
||||
from_email: fromEmail,
|
||||
to_email: toEmail,
|
||||
message_id: emailResult.MessageID,
|
||||
ai_analysis: {
|
||||
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(
|
||||
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