mirror of
https://github.com/FranP-code/inbox-negotiator.git
synced 2025-10-13 00:42:26 +00:00
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:
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user