feat: Add manual response dialog and update debt status handling

- Introduced ManualResponseDialog component for user-initiated responses when AI analysis is unclear.
- Updated DebtTimeline to include AlertTriangle icon for debts requiring manual review.
- Enhanced supabase functions to handle new debt status 'requires_manual_review' and message type 'manual_response'.
- Implemented email variable processing utilities to support dynamic email content generation.
- Created tests for email variable extraction and replacement functions.
- Updated database schema to accommodate new statuses and message types, including relevant constraints and indexes.
- Adjusted negotiation and email sending logic to ensure proper handling of manual responses and variable replacements.
This commit is contained in:
2025-06-08 01:49:19 -03:00
parent bddc3a344d
commit 7c91b625a6
13 changed files with 1059 additions and 171 deletions

View File

@@ -506,6 +506,8 @@ serve(async (req) => {
debt.metadata?.aiEmail,
);
console.log({ analysis });
// Store the conversation message
const { error: messageError } = await supabaseClient
.from("conversation_messages")
@@ -557,11 +559,11 @@ serve(async (req) => {
nextAction = analysis.suggestedNextAction;
break;
case "request_info":
newStatus = "awaiting_response";
newStatus = "requires_manual_review";
nextAction = "escalate_to_user";
break;
default:
newStatus = "awaiting_response";
newStatus = "requires_manual_review";
nextAction = "escalate_to_user";
}
@@ -670,6 +672,12 @@ serve(async (req) => {
});
}
console.log({
shouldAutoRespond,
analysisIntent: analysis.intent,
analysisConfidence: analysis.confidence,
});
// If auto-response is recommended and confidence is high, trigger negotiation
if (
shouldAutoRespond && analysis.confidence > 0.8 &&

View File

@@ -73,13 +73,42 @@ interface DebtRecord {
isDebtCollection?: boolean;
subject?: string;
fromEmail?: string;
toEmail?: string;
aiEmail?: {
subject: string;
body: string;
strategy: string;
confidence: number;
reasoning: string;
customTerms: Record<string, unknown>;
};
lastResponse?: {
analysis: Record<string, unknown>;
receivedAt: string;
fromEmail: string;
subject: string;
};
};
}
interface CounterOfferContext {
previousResponse: string;
extractedTerms: {
proposedAmount?: number;
proposedPaymentPlan?: string;
monthlyAmount?: number;
numberOfPayments?: number;
totalAmount?: number;
paymentFrequency?: string;
};
sentiment: string;
}
// AI-powered negotiation email generator
async function generateNegotiationEmail(
record: DebtRecord,
personalData: PersonalData,
counterOfferContext?: CounterOfferContext,
) {
try {
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
@@ -88,12 +117,9 @@ async function generateNegotiationEmail(
return generateFallbackEmail(record, personalData);
}
const result = await generateObject({
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system:
`You are an expert debt negotiation advisor specializing in FDCPA-compliant email generation.
// Build context-aware system prompt
let systemPrompt =
`You are an expert debt negotiation advisor specializing in FDCPA-compliant email generation.
Create professional, formal negotiation emails that:
- Include appropriate subject line and email body
- Follow Fair Debt Collection Practices Act requirements
@@ -102,34 +128,69 @@ async function generateNegotiationEmail(
- Use {{ variable }} syntax for missing or uncertain data (like account numbers, specific dates)
- Maintain professional tone throughout
- Include proper business letter formatting
Strategy guidelines based on amount:
- Extension: For temporary hardship, usually < $500
- Installment: For manageable monthly payments, $500-$2000
- Installment: For manageable monthly payments, $500-$2000
- Settlement: For significant savings, typically $2000+
- Dispute: If debt validity is questionable
For missing personal data, use appropriate placeholders.
For uncertain information like account numbers, use {{ Account Number }} format.`,
prompt: `Generate a complete negotiation email for this debt:
For uncertain information like account numbers, use {{ Account Number }} format.`;
// Build context-aware prompt
let prompt = `Generate a complete negotiation email for this debt:
Debt Amount: $${record.amount}
Vendor: ${record.vendor}
Description: ${record.description || "Not specified"}
Due Date: ${record.due_date || "Not specified"}
Email Content Preview: ${record.raw_email.substring(0, 500)}...
Personal Data Available:
- Full Name: ${personalData.full_name || "{{ Full Name }}"}
- Address: ${personalData.address_line_1 || "{{ Address Line 1 }}"} ${
personalData.address_line_2 ? personalData.address_line_2 : ""
}
personalData.address_line_2 ? personalData.address_line_2 : ""
}
- City: ${personalData.city || "{{ City }}"}
- State: ${personalData.state || "{{ State }}"}
- Zip: ${personalData.zip_code || "{{ Zip Code }}"}
- Phone: ${personalData.phone_number || "{{ Phone Number }}"}
Create a professional negotiation email with subject and body.`,
- Phone: ${personalData.phone_number || "{{ Phone Number }}"}`;
// Add counter-offer context if this is a response to a creditor's counter-offer
if (counterOfferContext) {
systemPrompt += `
IMPORTANT: This is a COUNTER-RESPONSE to a creditor's previous response. You must:
- Acknowledge their previous response professionally
- Address their specific terms or concerns
- Make a strategic counter-offer that moves toward resolution
- Show willingness to negotiate while protecting the debtor's interests
- Reference specific amounts or terms they mentioned
- Maintain momentum in the negotiation process`;
prompt += `
CREDITOR'S PREVIOUS RESPONSE CONTEXT:
- Their Response: ${counterOfferContext.previousResponse}
- Sentiment: ${counterOfferContext.sentiment}
- Extracted Terms: ${JSON.stringify(counterOfferContext.extractedTerms)}
Generate a strategic counter-response that acknowledges their position and makes a reasonable counter-offer.`;
} else {
prompt += `
Create a professional initial negotiation email with subject and body.`;
}
console.log({ systemPrompt, prompt });
const result = await generateObject({
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system: systemPrompt,
prompt: prompt,
schema: negotiationEmailSchema,
});
@@ -310,7 +371,10 @@ Deno.serve(async (req) => {
);
// For webhook calls, we'll get the userId from the request body along with the record
const { record }: { record: DebtRecord } = await req.json();
const { record, counterOfferContext }: {
record: DebtRecord;
counterOfferContext?: CounterOfferContext;
} = await req.json();
if (!record || !record.user_id) {
return new Response(
@@ -328,7 +392,12 @@ Deno.serve(async (req) => {
// Use the record as-is for webhook calls
const personalData = await fetchUserPersonalData(supabaseClient, user.id);
return await processNegotiation(supabaseClient, record, personalData);
return await processNegotiation(
supabaseClient,
record,
personalData,
counterOfferContext,
);
} else {
// This is an authenticated user call
if (!authHeader) {
@@ -452,21 +521,43 @@ async function processNegotiation(
supabaseClient: ReturnType<typeof createClient>,
record: DebtRecord,
personalData: PersonalData,
counterOfferContext?: CounterOfferContext,
): Promise<Response>;
async function processNegotiation(
supabaseClient: unknown,
record: DebtRecord,
personalData: PersonalData,
counterOfferContext?: CounterOfferContext,
): Promise<Response>;
async function processNegotiation(
supabaseClient: unknown,
record: DebtRecord,
personalData: PersonalData,
counterOfferContext?: CounterOfferContext,
): Promise<Response> {
const client = supabaseClient as ReturnType<typeof createClient>;
// Generate AI-powered negotiation email
const emailResult = await generateNegotiationEmail(record, personalData);
const emailResult = await generateNegotiationEmail(
record,
personalData,
counterOfferContext,
);
// Create conversation message for the AI-generated response
const messageType = counterOfferContext
? "counter_offer"
: "negotiation_sent";
await client.from("conversation_messages").insert({
debt_id: record.id,
message_type: messageType,
direction: "outbound",
subject: emailResult.subject,
body: emailResult.body,
from_email: record.metadata?.toEmail || "user@example.com",
to_email: record.metadata?.fromEmail || record.vendor,
message_id: `ai-generated-${Date.now()}`,
});
// Update debt record with AI-generated content - using provided client
const { error: updateError } = await client
@@ -474,7 +565,7 @@ async function processNegotiation(
.update({
negotiated_plan: `Subject: ${emailResult.subject}\n\n${emailResult.body}`,
projected_savings: emailResult.projectedSavings,
status: "negotiating",
status: counterOfferContext ? "counter_negotiating" : "negotiating",
metadata: {
...record.metadata,
aiEmail: {
@@ -498,13 +589,17 @@ async function processNegotiation(
.from("audit_logs")
.insert({
debt_id: record.id,
action: "negotiation_generated",
action: counterOfferContext
? "auto_counter_response_generated"
: "negotiation_generated",
details: {
strategy: emailResult.strategy,
amount: record.amount,
projected_savings: emailResult.projectedSavings,
ai_confidence: emailResult.confidenceLevel,
reasoning: emailResult.reasoning,
isCounterResponse: !!counterOfferContext,
counterOfferContext: counterOfferContext || null,
},
});

View File

@@ -3,6 +3,7 @@
This function sends negotiated emails via Postmark:
- Validates user has server token configured
- Processes email variables and replaces placeholders
- Sends the approved negotiation email to the debt collector
- Updates debt status and logs the action
- Ensures FDCPA compliance
@@ -81,6 +82,100 @@ async function sendEmailViaPostmark(
return await response.json();
}
// Extract variables from text in {{ variable }} format
function extractVariables(text: string): string[] {
const variableRegex = /\{\{\s*([^}]+)\s*\}\}/g;
const matches: string[] = [];
let match;
while ((match = variableRegex.exec(text)) !== null) {
if (!matches.includes(match[1].trim())) {
matches.push(match[1].trim());
}
}
return matches;
}
// Replace variables in text with their values
function replaceVariables(
text: string,
variables: Record<string, string>,
): string {
let result = text;
Object.entries(variables).forEach(([key, value]) => {
const regex = new RegExp(
`\\{\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}\\}`,
"g",
);
result = result.replace(regex, value);
});
return result;
}
// Load variables from database for a specific debt
async function loadVariablesFromDatabase(
supabaseClient: any,
debtId: string,
): Promise<Record<string, string>> {
try {
const { data: dbVariables, error } = await supabaseClient
.from("debt_variables")
.select("variable_name, variable_value")
.eq("debt_id", debtId);
if (error) throw error;
const loadedVariables: Record<string, string> = {};
dbVariables?.forEach((dbVar: any) => {
loadedVariables[dbVar.variable_name] = dbVar.variable_value || "";
});
return loadedVariables;
} catch (error) {
console.error("Error loading variables:", error);
return {};
}
}
// Process email template by replacing variables with their values
async function processEmailTemplate(
supabaseClient: any,
debtId: string,
subject: string,
body: string,
): Promise<
{
processedSubject: string;
processedBody: string;
hasUnfilledVariables: boolean;
}
> {
// Extract all variables from subject and body
const allText = `${subject} ${body}`;
const extractedVars = extractVariables(allText);
// Load saved variables from database
const savedVariables = await loadVariablesFromDatabase(
supabaseClient,
debtId,
);
// Check if all variables have values
const unfilledVariables = extractedVars.filter((variable) =>
!savedVariables[variable] || savedVariables[variable].trim() === ""
);
const hasUnfilledVariables = unfilledVariables.length > 0;
// Replace variables in subject and body
const processedSubject = replaceVariables(subject, savedVariables);
const processedBody = replaceVariables(body, savedVariables);
return {
processedSubject,
processedBody,
hasUnfilledVariables,
};
}
// Extract email address from various formats
function extractEmailAddress(emailString: string): string {
// Handle formats like "Name <email@domain.com>" or just "email@domain.com"
@@ -209,8 +304,8 @@ Deno.serve(async (req) => {
);
}
// Extract email details
const { subject, body } = debt.metadata.aiEmail;
// Extract email details and process variables
const { subject: rawSubject, body: rawBody } = debt.metadata.aiEmail;
const fromEmail = debt.metadata?.toEmail || user.email;
if (!fromEmail) {
@@ -223,6 +318,33 @@ Deno.serve(async (req) => {
);
}
// Process email template and replace variables
const { processedSubject, processedBody, hasUnfilledVariables } =
await processEmailTemplate(
supabaseClient,
debtId,
rawSubject,
rawBody,
);
// Check if there are unfilled variables
if (hasUnfilledVariables) {
return new Response(
JSON.stringify({
error: "Email contains unfilled variables",
details:
"Please fill in all required variables before sending the email.",
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const subject = processedSubject;
const body = processedBody;
// Determine recipient email
let toEmail = debt.vendor;
if (debt.metadata?.fromEmail) {

View File

@@ -0,0 +1,49 @@
-- Add manual review status and message type
-- This migration adds support for manual review when AI can't determine creditor intent
-- Update debts table status constraint to include requires_manual_review
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',
'requires_manual_review',
'accepted',
'rejected',
'settled',
'failed',
'opted_out'
));
-- Update conversation_messages table message_type constraint to include manual_response
ALTER TABLE conversation_messages
DROP CONSTRAINT IF EXISTS conversation_messages_message_type_check;
ALTER TABLE conversation_messages
ADD CONSTRAINT conversation_messages_message_type_check
CHECK (message_type IN (
'initial_debt',
'negotiation_sent',
'response_received',
'counter_offer',
'acceptance',
'rejection',
'manual_response'
));
-- Add comments for documentation
COMMENT ON CONSTRAINT debts_status_check ON debts IS 'Valid debt statuses including manual review for unclear AI responses';
COMMENT ON CONSTRAINT conversation_messages_message_type_check ON conversation_messages IS 'Valid message types including manual responses from users';
-- Add index for manual review status for performance
CREATE INDEX IF NOT EXISTS idx_debts_manual_review ON debts(status) WHERE status = 'requires_manual_review';
-- Update RLS policies to handle manual responses (already covered by existing policies)
-- No additional RLS changes needed as existing policies cover manual_response message type