Complete project disabling - all endpoints now return disabled message while keeping auth functional

Co-authored-by: FranP-code <76450203+FranP-code@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-09-01 19:16:52 +00:00
parent bcd0c0d535
commit 4429f27e75
17 changed files with 2534 additions and 3136 deletions

View File

@@ -0,0 +1,854 @@
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;
}
// Retrieve full conversation history for context
async function getConversationHistory(
supabaseClient: any,
debtId: string,
) {
try {
const { data: messages, error } = await supabaseClient
.from("conversation_messages")
.select("*")
.eq("debt_id", debtId)
.order("created_at", { ascending: true });
if (error) {
console.error("Error fetching conversation history:", error);
return [];
}
return messages || [];
} catch (error) {
console.error("Error in getConversationHistory:", error);
return [];
}
}
// AI-powered response analysis
async function analyzeEmailResponse(
supabaseClient: any,
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,
});
// Get full conversation history for better context
const conversationHistory = await getConversationHistory(
supabaseClient,
debtId,
);
console.log({
debtId,
fromEmail,
subject,
body,
originalNegotiation,
conversationHistoryLength: conversationHistory.length,
});
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, the "suggestedNextAction" is "escalate_to_user", OR if the message is vague/lacks specific financial terms (e.g., "think of a better offer", "we need more", etc.). Only set to 'false' for clear, specific counter-offers with concrete terms.`;
// Build conversation history context
// const conversationContext = conversationHistory.length > 0
// ? `--- FULL CONVERSATION HISTORY ---
// ${
// conversationHistory.map((msg, index) =>
// `${
// index + 1
// }. ${msg.direction.toUpperCase()} - ${msg.message_type} (${
// new Date(msg.created_at).toLocaleDateString()
// })
// Subject: ${msg.subject || "N/A"}
// Body: ${msg.body.substring(0, 500)}${msg.body.length > 500 ? "..." : ""}
// ${msg.ai_analysis ? `AI Analysis: ${JSON.stringify(msg.ai_analysis)}` : ""}
// ---`
// ).join("\n")
// }
// `
// : "";
const conversationContext = conversationHistory.length > 0
? `--- FULL CONVERSATION HISTORY ---
${
conversationHistory.map((msg, index) =>
`${
index + 1
}. ${msg.direction.toUpperCase()} - ${msg.message_type} (${
new Date(msg.created_at).toLocaleDateString()
})
Subject: ${msg.subject || "N/A"}
Body: ${msg.body}
${msg.ai_analysis ? `AI Analysis: ${JSON.stringify(msg.ai_analysis)}` : ""}
---`
).join("\n")
}
`
: "";
const prompt =
`Analyze the following email and extract the financial details and intent, populating the JSON object according to your system instructions.
--- CURRENT EMAIL TO ANALYZE ---
From: ${fromEmail}
Subject: ${subject}
Body: ${body}
${conversationContext}
${
originalNegotiation
? `--- MOST RECENT NEGOTIATION CONTEXT ---
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(
supabaseClient,
debtId,
fromEmail,
subject,
body,
debt.metadata?.aiEmail,
);
console.log({ analysis });
// 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;
// More conservative auto-response logic for counter-offers
shouldAutoRespond = !analysis.requiresUserReview &&
analysis.confidence > 0.9 && // Increased confidence threshold
analysis.extractedTerms && // Must have specific terms
(analysis.extractedTerms.proposedAmount ||
analysis.extractedTerms.paymentTerms) && // Must have concrete financial terms
body.length > 20; // Must be more than a vague message
nextAction = analysis.suggestedNextAction;
break;
case "request_info":
newStatus = "requires_manual_review";
nextAction = "escalate_to_user";
break;
default:
newStatus = "requires_manual_review";
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,
},
});
}
console.log({
shouldAutoRespond,
analysisIntent: analysis.intent,
analysisConfidence: analysis.confidence,
});
// TEMPORARILY DISABLED: Auto-responses need more testing
// TODO: Re-enable after thorough testing and user preference settings
const AUTO_RESPONSES_ENABLED = true;
const conversationHistory = await getConversationHistory(
supabaseClient,
debtId,
);
console.log({
conversationHistoryLength: conversationHistory.length,
});
// If auto-response is recommended and confidence is high, trigger negotiation
if (
AUTO_RESPONSES_ENABLED &&
shouldAutoRespond && analysis.confidence > 0.8 &&
analysis.intent === "counter_offer" &&
// the length of the conversation isn't bigger than 2 messages
conversationHistory.length <= 2
) {
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" },
},
);
}
});

View File

@@ -1,854 +1,25 @@
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",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
// 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;
}
// Retrieve full conversation history for context
async function getConversationHistory(
supabaseClient: any,
debtId: string,
) {
try {
const { data: messages, error } = await supabaseClient
.from("conversation_messages")
.select("*")
.eq("debt_id", debtId)
.order("created_at", { ascending: true });
if (error) {
console.error("Error fetching conversation history:", error);
return [];
}
return messages || [];
} catch (error) {
console.error("Error in getConversationHistory:", error);
return [];
}
}
// AI-powered response analysis
async function analyzeEmailResponse(
supabaseClient: any,
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,
});
// Get full conversation history for better context
const conversationHistory = await getConversationHistory(
supabaseClient,
debtId,
);
console.log({
debtId,
fromEmail,
subject,
body,
originalNegotiation,
conversationHistoryLength: conversationHistory.length,
});
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, the "suggestedNextAction" is "escalate_to_user", OR if the message is vague/lacks specific financial terms (e.g., "think of a better offer", "we need more", etc.). Only set to 'false' for clear, specific counter-offers with concrete terms.`;
// Build conversation history context
// const conversationContext = conversationHistory.length > 0
// ? `--- FULL CONVERSATION HISTORY ---
// ${
// conversationHistory.map((msg, index) =>
// `${
// index + 1
// }. ${msg.direction.toUpperCase()} - ${msg.message_type} (${
// new Date(msg.created_at).toLocaleDateString()
// })
// Subject: ${msg.subject || "N/A"}
// Body: ${msg.body.substring(0, 500)}${msg.body.length > 500 ? "..." : ""}
// ${msg.ai_analysis ? `AI Analysis: ${JSON.stringify(msg.ai_analysis)}` : ""}
// ---`
// ).join("\n")
// }
// `
// : "";
const conversationContext = conversationHistory.length > 0
? `--- FULL CONVERSATION HISTORY ---
${
conversationHistory.map((msg, index) =>
`${
index + 1
}. ${msg.direction.toUpperCase()} - ${msg.message_type} (${
new Date(msg.created_at).toLocaleDateString()
})
Subject: ${msg.subject || "N/A"}
Body: ${msg.body}
${msg.ai_analysis ? `AI Analysis: ${JSON.stringify(msg.ai_analysis)}` : ""}
---`
).join("\n")
}
`
: "";
const prompt =
`Analyze the following email and extract the financial details and intent, populating the JSON object according to your system instructions.
--- CURRENT EMAIL TO ANALYZE ---
From: ${fromEmail}
Subject: ${subject}
Body: ${body}
${conversationContext}
${
originalNegotiation
? `--- MOST RECENT NEGOTIATION CONTEXT ---
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) => {
Deno.serve(async (req) => {
// Handle CORS preflight requests
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" },
},
);
// Project has been disabled
return new Response(
JSON.stringify({
error: "Project Disabled",
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
}),
{
status: 503,
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(
supabaseClient,
debtId,
fromEmail,
subject,
body,
debt.metadata?.aiEmail,
);
console.log({ analysis });
// 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;
// More conservative auto-response logic for counter-offers
shouldAutoRespond = !analysis.requiresUserReview &&
analysis.confidence > 0.9 && // Increased confidence threshold
analysis.extractedTerms && // Must have specific terms
(analysis.extractedTerms.proposedAmount ||
analysis.extractedTerms.paymentTerms) && // Must have concrete financial terms
body.length > 20; // Must be more than a vague message
nextAction = analysis.suggestedNextAction;
break;
case "request_info":
newStatus = "requires_manual_review";
nextAction = "escalate_to_user";
break;
default:
newStatus = "requires_manual_review";
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,
},
});
}
console.log({
shouldAutoRespond,
analysisIntent: analysis.intent,
analysisConfidence: analysis.confidence,
});
// TEMPORARILY DISABLED: Auto-responses need more testing
// TODO: Re-enable after thorough testing and user preference settings
const AUTO_RESPONSES_ENABLED = true;
const conversationHistory = await getConversationHistory(
supabaseClient,
debtId,
);
console.log({
conversationHistoryLength: conversationHistory.length,
});
// If auto-response is recommended and confidence is high, trigger negotiation
if (
AUTO_RESPONSES_ENABLED &&
shouldAutoRespond && analysis.confidence > 0.8 &&
analysis.intent === "counter_offer" &&
// the length of the conversation isn't bigger than 2 messages
conversationHistory.length <= 2
) {
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" },
},
);
}
});
);
});

View File

@@ -0,0 +1,235 @@
/*
# Debt Approval Edge Function
This function handles debt approval without sending emails:
- Updates debt status to "approved"
- Logs the approval action
- Saves finalized negotiation data
- Updates metadata with approval timestamp
*/
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
interface ApproveDebtRequest {
debtId: string;
approvalNote?: string;
}
interface DebtRecord {
id: string;
vendor: string;
amount: number;
raw_email: string;
negotiated_plan?: string;
status: string;
metadata?: {
aiEmail?: {
subject: string;
body: string;
strategy: string;
};
fromEmail?: string;
};
}
Deno.serve(async (req) => {
// Handle CORS preflight requests
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
if (req.method !== "POST") {
return new Response(
JSON.stringify({ error: "Method not allowed" }),
{
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Initialize Supabase client with auth context
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: req.headers.get("Authorization") ?? "" },
},
},
);
// Get the authenticated user
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Authorization header required" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const token = authHeader.replace("Bearer ", "");
const { data: userData } = await supabaseClient.auth.getUser(token);
const user = userData.user;
if (!user) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const { debtId, approvalNote }: ApproveDebtRequest = await req.json();
if (!debtId) {
return new Response(
JSON.stringify({ error: "Missing debtId" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch debt record - RLS will ensure user can only access their own debts
const { data: debtRecord, error: debtError } = await supabaseClient
.from("debts")
.select("*")
.eq("id", debtId)
.eq("user_id", user.id)
.single();
if (debtError || !debtRecord) {
return new Response(
JSON.stringify({ error: "Debt not found or access denied" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const debt = debtRecord as DebtRecord;
// Validate that the debt is in negotiating status
if (debt.status !== "negotiating") {
return new Response(
JSON.stringify({
error: "Debt is not in negotiating status",
currentStatus: debt.status,
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Validate that AI email exists
if (!debt.metadata?.aiEmail) {
return new Response(
JSON.stringify({ error: "No AI-generated email found for this debt" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const currentTimestamp = new Date().toISOString();
// Update debt status to approved - using authenticated client
const { error: updateError } = await supabaseClient
.from("debts")
.update({
status: "approved",
metadata: {
...debt.metadata,
approved: {
approvedAt: currentTimestamp,
approvalNote: approvalNote || "Approved without sending email",
strategy: debt.metadata.aiEmail.strategy,
finalizedSubject: debt.metadata.aiEmail.subject,
finalizedBody: debt.metadata.aiEmail.body,
},
},
})
.eq("id", debtId);
if (updateError) {
console.error("Error updating debt status:", updateError);
return new Response(
JSON.stringify({
error: "Failed to update debt status",
details: updateError.message,
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Log the approval action - using authenticated client
const { error: auditError } = await supabaseClient
.from("audit_logs")
.insert({
debt_id: debtId,
action: "debt_approved",
details: {
approvedAt: currentTimestamp,
approvalNote: approvalNote || "Approved without sending email",
strategy: debt.metadata.aiEmail.strategy,
subject: debt.metadata.aiEmail.subject,
vendor: debt.vendor,
amount: debt.amount,
},
});
if (auditError) {
console.warn("Failed to log approval action:", auditError);
// Don't fail the entire operation for audit log issues
}
return new Response(
JSON.stringify({
success: true,
debtId: debtId,
status: "approved",
approvedAt: currentTimestamp,
vendor: debt.vendor,
amount: debt.amount,
}),
{
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
} catch (error) {
console.error("Error in approve-debt function:", error);
return new Response(
JSON.stringify({
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
});

View File

@@ -8,8 +8,6 @@
- Updates metadata with approval timestamp
*/
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
@@ -17,219 +15,21 @@ const corsHeaders = {
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
interface ApproveDebtRequest {
debtId: string;
approvalNote?: string;
}
interface DebtRecord {
id: string;
vendor: string;
amount: number;
raw_email: string;
negotiated_plan?: string;
status: string;
metadata?: {
aiEmail?: {
subject: string;
body: string;
strategy: string;
};
fromEmail?: string;
};
}
Deno.serve(async (req) => {
// Handle CORS preflight requests
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
if (req.method !== "POST") {
return new Response(
JSON.stringify({ error: "Method not allowed" }),
{
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
// Project has been disabled
return new Response(
JSON.stringify({
error: "Project Disabled",
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
}),
{
status: 503,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
// Initialize Supabase client with auth context
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: req.headers.get("Authorization") ?? "" },
},
},
);
// Get the authenticated user
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Authorization header required" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const token = authHeader.replace("Bearer ", "");
const { data: userData } = await supabaseClient.auth.getUser(token);
const user = userData.user;
if (!user) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const { debtId, approvalNote }: ApproveDebtRequest = await req.json();
if (!debtId) {
return new Response(
JSON.stringify({ error: "Missing debtId" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch debt record - RLS will ensure user can only access their own debts
const { data: debtRecord, error: debtError } = await supabaseClient
.from("debts")
.select("*")
.eq("id", debtId)
.eq("user_id", user.id)
.single();
if (debtError || !debtRecord) {
return new Response(
JSON.stringify({ error: "Debt not found or access denied" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const debt = debtRecord as DebtRecord;
// Validate that the debt is in negotiating status
if (debt.status !== "negotiating") {
return new Response(
JSON.stringify({
error: "Debt is not in negotiating status",
currentStatus: debt.status,
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Validate that AI email exists
if (!debt.metadata?.aiEmail) {
return new Response(
JSON.stringify({ error: "No AI-generated email found for this debt" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const currentTimestamp = new Date().toISOString();
// Update debt status to approved - using authenticated client
const { error: updateError } = await supabaseClient
.from("debts")
.update({
status: "approved",
metadata: {
...debt.metadata,
approved: {
approvedAt: currentTimestamp,
approvalNote: approvalNote || "Approved without sending email",
strategy: debt.metadata.aiEmail.strategy,
finalizedSubject: debt.metadata.aiEmail.subject,
finalizedBody: debt.metadata.aiEmail.body,
},
},
})
.eq("id", debtId);
if (updateError) {
console.error("Error updating debt status:", updateError);
return new Response(
JSON.stringify({
error: "Failed to update debt status",
details: updateError.message,
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Log the approval action - using authenticated client
const { error: auditError } = await supabaseClient
.from("audit_logs")
.insert({
debt_id: debtId,
action: "debt_approved",
details: {
approvedAt: currentTimestamp,
approvalNote: approvalNote || "Approved without sending email",
strategy: debt.metadata.aiEmail.strategy,
subject: debt.metadata.aiEmail.subject,
vendor: debt.vendor,
amount: debt.amount,
},
});
if (auditError) {
console.warn("Failed to log approval action:", auditError);
// Don't fail the entire operation for audit log issues
}
return new Response(
JSON.stringify({
success: true,
debtId: debtId,
status: "approved",
approvedAt: currentTimestamp,
vendor: debt.vendor,
amount: debt.amount,
}),
{
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
} catch (error) {
console.error("Error in approve-debt function:", error);
return new Response(
JSON.stringify({
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
});
);
});

View File

@@ -0,0 +1,635 @@
/*
# Debt Negotiation AI Edge Function
This function generates FDCPA-compliant negotiation responses using AI analysis:
- Analyzes debt details and vendor information
- Generates personalized negotiation strategies
- Creates contextually appropriate response letters
- Ensures FDCPA compliance
*/
import { createClient } from "npm:@supabase/supabase-js@2";
import { generateObject } from "npm:ai@4.3.16";
import { createGoogleGenerativeAI } from "npm:@ai-sdk/google@1.2.19";
import { z } from "npm:zod@3.23.8";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
// Schema for AI-generated negotiation email
const negotiationEmailSchema = z.object({
subject: z.string().describe("The email subject line"),
body: z.string().describe(
"The complete email body text with proper formatting and placeholders for missing data",
),
strategy: z.enum(["extension", "installment", "settlement", "dispute"])
.describe("The recommended negotiation strategy"),
confidenceLevel: z.number().min(0).max(1).describe(
"Confidence in the strategy recommendation",
),
projectedSavings: z.number().min(0).describe(
"Estimated savings from this strategy",
),
reasoning: z.string().describe("Explanation of why this strategy was chosen"),
customTerms: z.object({
extensionDays: z.number().optional().describe(
"Days for extension if applicable",
),
installmentMonths: z.number().optional().describe(
"Number of months for installment plan",
),
settlementPercentage: z.number().optional().describe(
"Settlement percentage (0-1) if applicable",
),
monthlyPayment: z.number().optional().describe(
"Monthly payment amount for installments",
),
}).describe("Custom terms based on the strategy"),
});
interface PersonalData {
full_name?: string;
address_line_1?: string;
address_line_2?: string;
city?: string;
state?: string;
zip_code?: string;
phone_number?: string;
}
interface DebtRecord {
id: string;
vendor: string;
amount: number;
raw_email: string;
description?: string;
due_date?: string;
user_id?: string;
metadata?: {
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");
if (!googleApiKey) {
console.warn("Google API key not configured, falling back to template");
return generateFallbackEmail(record, personalData);
}
// 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
- Use the provided personal data in proper letter format
- Include specific negotiation terms based on debt amount
- 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
- 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.`;
// 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 : ""
}
- City: ${personalData.city || "{{ City }}"}
- State: ${personalData.state || "{{ State }}"}
- Zip: ${personalData.zip_code || "{{ Zip Code }}"}
- 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,
});
return result.object;
} catch (error) {
console.error("AI email generation error:", error);
return generateFallbackEmail(record, personalData);
}
}
// Fallback email generation when AI is unavailable
function generateFallbackEmail(record: DebtRecord, personalData: PersonalData) {
let strategy: "extension" | "installment" | "settlement" | "dispute" =
"extension";
let projectedSavings = 0;
let customTerms = {};
if (record.amount < 500) {
strategy = "extension";
projectedSavings = 0;
customTerms = { extensionDays: 30 };
} else if (record.amount >= 500 && record.amount < 2000) {
strategy = "installment";
projectedSavings = record.amount * 0.1;
customTerms = {
installmentMonths: 3,
monthlyPayment: Math.round(record.amount / 3 * 100) / 100,
};
} else {
strategy = "settlement";
projectedSavings = record.amount * 0.4;
customTerms = { settlementPercentage: 0.6 };
}
const subject =
`Account Number: {{ Account Number }} - Payment Arrangement Request`;
const body = generateNegotiationLetter(record, strategy, personalData);
return {
subject,
body,
strategy,
confidenceLevel: 0.7,
projectedSavings,
reasoning: "Generated using rule-based fallback logic",
customTerms,
};
}
// Generate negotiation letter for fallback
function generateNegotiationLetter(
record: DebtRecord,
strategy: string,
personalData: PersonalData,
): string {
const senderInfo = `${personalData.full_name || "{{ Full Name }}"}
${personalData.address_line_1 || "{{ Address Line 1 }}"} ${
personalData.address_line_2 ? personalData.address_line_2 : ""
}
${personalData.city || "{{ City }}"}, ${personalData.state || "{{ State }}"} ${
personalData.zip_code || "{{ Zip Code }}"
}
${personalData.phone_number || "{{ Phone Number }}"}
{{ Date }}`;
const vendorDomain = record.vendor.includes("@")
? record.vendor.split("@")[1]
: record.vendor;
const companyName = vendorDomain.split(".")[0].toUpperCase();
const recipientInfo = `${companyName} Collections Department
{{ Collection Agency Address }}`;
const baseResponse = `${senderInfo}
${recipientInfo}
Subject: Account Number: {{ Account Number }}
To Whom It May Concern,
This letter is regarding the debt associated with the account number referenced above, originally with ${record.vendor}, in the amount of $${
record.amount.toFixed(2)
}.
I am writing to propose a payment arrangement to resolve this matter.`;
let proposal = "";
switch (strategy) {
case "extension": {
proposal =
` I respectfully request a 30-day extension to arrange full payment. I anticipate being able to settle this account in full by {{ Proposed Payment Date }}.
During this extension period, I request that no additional fees or interest be applied to maintain the current balance.`;
break;
}
case "installment": {
const monthlyPayment = (record.amount / 3).toFixed(2);
proposal = ` I am able to pay the full balance of $${
record.amount.toFixed(2)
} through an installment plan. I propose to make three (3) equal monthly payments of $${monthlyPayment}, with the first payment to be made on {{ Proposed Start Date }}.`;
break;
}
case "settlement": {
const settlementAmount = (record.amount * 0.6).toFixed(2);
proposal =
` I would like to propose a lump-sum settlement offer of $${settlementAmount} (60% of the current balance) to resolve this matter completely.
This settlement would be paid within 10 business days of written acceptance of this offer. Upon payment, I request written confirmation that this account will be considered paid in full and closed.`;
break;
}
case "dispute": {
proposal =
` I am formally disputing this debt and requesting validation under Section 809(b) of the Fair Debt Collection Practices Act.
Please provide:
- Verification of the debt amount
- Name and address of the original creditor
- Copy of any judgment (if applicable)
- Verification of your authority to collect this debt
Until proper validation is provided, I request that all collection activities cease.`;
break;
}
}
const closingResponse = `
Please confirm in writing your acceptance of this installment plan. Upon receiving your written agreement, I will begin making the proposed payments according to the schedule.
In accordance with the Fair Debt Collection Practices Act (FDCPA), I request validation of this debt. Please provide verification of the debt, including documentation showing the original creditor, the amount owed, and that you are legally authorized to collect this debt. I understand that you must cease collection efforts until this validation is provided.
I look forward to your prompt response and confirmation of this payment arrangement.
Sincerely,
${personalData.full_name || "{{ Your Typed Name }}"}`;
return baseResponse + proposal + closingResponse;
}
Deno.serve(async (req) => {
// Handle CORS preflight requests
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
// Project has been disabled
return new Response(
JSON.stringify({
error: "Project Disabled",
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
}),
{
status: 503,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
});
if (req.method !== "POST") {
return new Response(
JSON.stringify({ error: "Method not allowed" }),
{
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Check if this is a webhook call (using service role) or authenticated user call
const authHeader = req.headers.get("Authorization");
const isServiceRoleCall = authHeader?.includes(
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "",
);
let user: { id: string } | null = null;
let supabaseClient;
if (isServiceRoleCall) {
// This is a webhook/service call - use admin client
supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
);
// For webhook calls, we'll get the userId from the request body along with the record
const { record, counterOfferContext }: {
record: DebtRecord;
counterOfferContext?: CounterOfferContext;
} = await req.json();
if (!record || !record.user_id) {
return new Response(
JSON.stringify({
error: "Missing record or user_id for service call",
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
user = { id: record.user_id };
// Use the record as-is for webhook calls
const personalData = await fetchUserPersonalData(supabaseClient, user.id);
return await processNegotiation(
supabaseClient,
record,
personalData,
counterOfferContext,
);
} else {
// This is an authenticated user call
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Authorization header required" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Initialize Supabase client with auth context
supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: authHeader },
},
},
);
const token = authHeader.replace("Bearer ", "");
const { data: userData } = await supabaseClient.auth.getUser(token);
user = userData.user;
if (!user) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const { debtId }: { debtId: string } = await req.json();
if (!debtId) {
return new Response(
JSON.stringify({ error: "Missing debtId" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch debt record - RLS will ensure user can only access their own debts
const { data: debtRecord, error: debtError } = await supabaseClient
.from("debts")
.select("*")
.eq("id", debtId)
.eq("user_id", user.id)
.single();
if (debtError || !debtRecord) {
return new Response(
JSON.stringify({ error: "Debt not found or access denied" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const record = debtRecord as DebtRecord;
const personalData = await fetchUserPersonalData(supabaseClient, user.id);
return await processNegotiation(supabaseClient, record, personalData);
}
} catch (error) {
console.error("Negotiation function error:", error);
const errorMessage = error instanceof Error
? error.message
: "An unknown error occurred" +
(Deno.env.get("NODE_ENV") === "development"
? `: ${JSON.stringify(error)}`
: "");
return new Response(
JSON.stringify({ error: errorMessage }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
});
// Helper function to fetch user personal data
async function fetchUserPersonalData(
supabaseClient: ReturnType<typeof createClient>,
userId: string,
): Promise<PersonalData>;
async function fetchUserPersonalData(
supabaseClient: unknown,
userId: string,
): Promise<PersonalData>;
async function fetchUserPersonalData(
supabaseClient: unknown,
userId: string,
): Promise<PersonalData> {
const client = supabaseClient as ReturnType<typeof createClient>;
const { data: userPersonalData, error: userError } = await client
.from("users")
.select(
"full_name, address_line_1, address_line_2, city, state, zip_code, phone_number",
)
.eq("id", userId)
.single();
if (userError) {
console.error("Error fetching user data:", userError);
}
return (userPersonalData as PersonalData) || {};
}
// Helper function to process the negotiation
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,
counterOfferContext,
);
// Create conversation message only for auto-responses (counter-offers)
// Regular negotiation generation doesn't create messages since they're not sent yet
if (counterOfferContext) {
await client.from("conversation_messages").insert({
debt_id: record.id,
message_type: "counter_offer",
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: `auto-counter-${Date.now()}`,
ai_analysis: {
strategy: emailResult.strategy,
confidence: emailResult.confidenceLevel,
projectedSavings: emailResult.projectedSavings,
isAutoGenerated: true,
},
});
}
// Update debt record with AI-generated content - using provided client
const { error: updateError } = await client
.from("debts")
.update({
negotiated_plan: `Subject: ${emailResult.subject}\n\n${emailResult.body}`,
projected_savings: emailResult.projectedSavings,
status: counterOfferContext ? "counter_negotiating" : "negotiating",
metadata: {
...record.metadata,
aiEmail: {
subject: emailResult.subject,
body: emailResult.body,
strategy: emailResult.strategy,
confidence: emailResult.confidenceLevel,
reasoning: emailResult.reasoning,
customTerms: emailResult.customTerms,
},
},
})
.eq("id", record.id);
if (updateError) {
throw updateError;
}
// Log the action - using provided client
await client
.from("audit_logs")
.insert({
debt_id: record.id,
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,
},
});
return new Response(
JSON.stringify({
success: true,
strategy: emailResult.strategy,
projected_savings: emailResult.projectedSavings,
confidence: emailResult.confidenceLevel,
reasoning: emailResult.reasoning,
subject: emailResult.subject,
body: emailResult.body,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}

View File

@@ -8,11 +8,6 @@
- Ensures FDCPA compliance
*/
import { createClient } from "npm:@supabase/supabase-js@2";
import { generateObject } from "npm:ai@4.3.16";
import { createGoogleGenerativeAI } from "npm:@ai-sdk/google@1.2.19";
import { z } from "npm:zod@3.23.8";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
@@ -20,605 +15,21 @@ const corsHeaders = {
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
// Schema for AI-generated negotiation email
const negotiationEmailSchema = z.object({
subject: z.string().describe("The email subject line"),
body: z.string().describe(
"The complete email body text with proper formatting and placeholders for missing data",
),
strategy: z.enum(["extension", "installment", "settlement", "dispute"])
.describe("The recommended negotiation strategy"),
confidenceLevel: z.number().min(0).max(1).describe(
"Confidence in the strategy recommendation",
),
projectedSavings: z.number().min(0).describe(
"Estimated savings from this strategy",
),
reasoning: z.string().describe("Explanation of why this strategy was chosen"),
customTerms: z.object({
extensionDays: z.number().optional().describe(
"Days for extension if applicable",
),
installmentMonths: z.number().optional().describe(
"Number of months for installment plan",
),
settlementPercentage: z.number().optional().describe(
"Settlement percentage (0-1) if applicable",
),
monthlyPayment: z.number().optional().describe(
"Monthly payment amount for installments",
),
}).describe("Custom terms based on the strategy"),
});
interface PersonalData {
full_name?: string;
address_line_1?: string;
address_line_2?: string;
city?: string;
state?: string;
zip_code?: string;
phone_number?: string;
}
interface DebtRecord {
id: string;
vendor: string;
amount: number;
raw_email: string;
description?: string;
due_date?: string;
user_id?: string;
metadata?: {
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");
if (!googleApiKey) {
console.warn("Google API key not configured, falling back to template");
return generateFallbackEmail(record, personalData);
}
// 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
- Use the provided personal data in proper letter format
- Include specific negotiation terms based on debt amount
- 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
- 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.`;
// 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 : ""
}
- City: ${personalData.city || "{{ City }}"}
- State: ${personalData.state || "{{ State }}"}
- Zip: ${personalData.zip_code || "{{ Zip Code }}"}
- 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,
});
return result.object;
} catch (error) {
console.error("AI email generation error:", error);
return generateFallbackEmail(record, personalData);
}
}
// Fallback email generation when AI is unavailable
function generateFallbackEmail(record: DebtRecord, personalData: PersonalData) {
let strategy: "extension" | "installment" | "settlement" | "dispute" =
"extension";
let projectedSavings = 0;
let customTerms = {};
if (record.amount < 500) {
strategy = "extension";
projectedSavings = 0;
customTerms = { extensionDays: 30 };
} else if (record.amount >= 500 && record.amount < 2000) {
strategy = "installment";
projectedSavings = record.amount * 0.1;
customTerms = {
installmentMonths: 3,
monthlyPayment: Math.round(record.amount / 3 * 100) / 100,
};
} else {
strategy = "settlement";
projectedSavings = record.amount * 0.4;
customTerms = { settlementPercentage: 0.6 };
}
const subject =
`Account Number: {{ Account Number }} - Payment Arrangement Request`;
const body = generateNegotiationLetter(record, strategy, personalData);
return {
subject,
body,
strategy,
confidenceLevel: 0.7,
projectedSavings,
reasoning: "Generated using rule-based fallback logic",
customTerms,
};
}
// Generate negotiation letter for fallback
function generateNegotiationLetter(
record: DebtRecord,
strategy: string,
personalData: PersonalData,
): string {
const senderInfo = `${personalData.full_name || "{{ Full Name }}"}
${personalData.address_line_1 || "{{ Address Line 1 }}"} ${
personalData.address_line_2 ? personalData.address_line_2 : ""
}
${personalData.city || "{{ City }}"}, ${personalData.state || "{{ State }}"} ${
personalData.zip_code || "{{ Zip Code }}"
}
${personalData.phone_number || "{{ Phone Number }}"}
{{ Date }}`;
const vendorDomain = record.vendor.includes("@")
? record.vendor.split("@")[1]
: record.vendor;
const companyName = vendorDomain.split(".")[0].toUpperCase();
const recipientInfo = `${companyName} Collections Department
{{ Collection Agency Address }}`;
const baseResponse = `${senderInfo}
${recipientInfo}
Subject: Account Number: {{ Account Number }}
To Whom It May Concern,
This letter is regarding the debt associated with the account number referenced above, originally with ${record.vendor}, in the amount of $${
record.amount.toFixed(2)
}.
I am writing to propose a payment arrangement to resolve this matter.`;
let proposal = "";
switch (strategy) {
case "extension": {
proposal =
` I respectfully request a 30-day extension to arrange full payment. I anticipate being able to settle this account in full by {{ Proposed Payment Date }}.
During this extension period, I request that no additional fees or interest be applied to maintain the current balance.`;
break;
}
case "installment": {
const monthlyPayment = (record.amount / 3).toFixed(2);
proposal = ` I am able to pay the full balance of $${
record.amount.toFixed(2)
} through an installment plan. I propose to make three (3) equal monthly payments of $${monthlyPayment}, with the first payment to be made on {{ Proposed Start Date }}.`;
break;
}
case "settlement": {
const settlementAmount = (record.amount * 0.6).toFixed(2);
proposal =
` I would like to propose a lump-sum settlement offer of $${settlementAmount} (60% of the current balance) to resolve this matter completely.
This settlement would be paid within 10 business days of written acceptance of this offer. Upon payment, I request written confirmation that this account will be considered paid in full and closed.`;
break;
}
case "dispute": {
proposal =
` I am formally disputing this debt and requesting validation under Section 809(b) of the Fair Debt Collection Practices Act.
Please provide:
- Verification of the debt amount
- Name and address of the original creditor
- Copy of any judgment (if applicable)
- Verification of your authority to collect this debt
Until proper validation is provided, I request that all collection activities cease.`;
break;
}
}
const closingResponse = `
Please confirm in writing your acceptance of this installment plan. Upon receiving your written agreement, I will begin making the proposed payments according to the schedule.
In accordance with the Fair Debt Collection Practices Act (FDCPA), I request validation of this debt. Please provide verification of the debt, including documentation showing the original creditor, the amount owed, and that you are legally authorized to collect this debt. I understand that you must cease collection efforts until this validation is provided.
I look forward to your prompt response and confirmation of this payment arrangement.
Sincerely,
${personalData.full_name || "{{ Your Typed Name }}"}`;
return baseResponse + proposal + closingResponse;
}
Deno.serve(async (req) => {
// Handle CORS preflight requests
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
if (req.method !== "POST") {
return new Response(
JSON.stringify({ error: "Method not allowed" }),
{
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Check if this is a webhook call (using service role) or authenticated user call
const authHeader = req.headers.get("Authorization");
const isServiceRoleCall = authHeader?.includes(
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "",
);
let user: { id: string } | null = null;
let supabaseClient;
if (isServiceRoleCall) {
// This is a webhook/service call - use admin client
supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
);
// For webhook calls, we'll get the userId from the request body along with the record
const { record, counterOfferContext }: {
record: DebtRecord;
counterOfferContext?: CounterOfferContext;
} = await req.json();
if (!record || !record.user_id) {
return new Response(
JSON.stringify({
error: "Missing record or user_id for service call",
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
user = { id: record.user_id };
// Use the record as-is for webhook calls
const personalData = await fetchUserPersonalData(supabaseClient, user.id);
return await processNegotiation(
supabaseClient,
record,
personalData,
counterOfferContext,
);
} else {
// This is an authenticated user call
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Authorization header required" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Initialize Supabase client with auth context
supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: authHeader },
},
},
);
const token = authHeader.replace("Bearer ", "");
const { data: userData } = await supabaseClient.auth.getUser(token);
user = userData.user;
if (!user) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const { debtId }: { debtId: string } = await req.json();
if (!debtId) {
return new Response(
JSON.stringify({ error: "Missing debtId" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch debt record - RLS will ensure user can only access their own debts
const { data: debtRecord, error: debtError } = await supabaseClient
.from("debts")
.select("*")
.eq("id", debtId)
.eq("user_id", user.id)
.single();
if (debtError || !debtRecord) {
return new Response(
JSON.stringify({ error: "Debt not found or access denied" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const record = debtRecord as DebtRecord;
const personalData = await fetchUserPersonalData(supabaseClient, user.id);
return await processNegotiation(supabaseClient, record, personalData);
}
} catch (error) {
console.error("Negotiation function error:", error);
const errorMessage = error instanceof Error
? error.message
: "An unknown error occurred" +
(Deno.env.get("NODE_ENV") === "development"
? `: ${JSON.stringify(error)}`
: "");
return new Response(
JSON.stringify({ error: errorMessage }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
});
// Helper function to fetch user personal data
async function fetchUserPersonalData(
supabaseClient: ReturnType<typeof createClient>,
userId: string,
): Promise<PersonalData>;
async function fetchUserPersonalData(
supabaseClient: unknown,
userId: string,
): Promise<PersonalData>;
async function fetchUserPersonalData(
supabaseClient: unknown,
userId: string,
): Promise<PersonalData> {
const client = supabaseClient as ReturnType<typeof createClient>;
const { data: userPersonalData, error: userError } = await client
.from("users")
.select(
"full_name, address_line_1, address_line_2, city, state, zip_code, phone_number",
)
.eq("id", userId)
.single();
if (userError) {
console.error("Error fetching user data:", userError);
}
return (userPersonalData as PersonalData) || {};
}
// Helper function to process the negotiation
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,
counterOfferContext,
);
// Create conversation message only for auto-responses (counter-offers)
// Regular negotiation generation doesn't create messages since they're not sent yet
if (counterOfferContext) {
await client.from("conversation_messages").insert({
debt_id: record.id,
message_type: "counter_offer",
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: `auto-counter-${Date.now()}`,
ai_analysis: {
strategy: emailResult.strategy,
confidence: emailResult.confidenceLevel,
projectedSavings: emailResult.projectedSavings,
isAutoGenerated: true,
},
});
}
// Update debt record with AI-generated content - using provided client
const { error: updateError } = await client
.from("debts")
.update({
negotiated_plan: `Subject: ${emailResult.subject}\n\n${emailResult.body}`,
projected_savings: emailResult.projectedSavings,
status: counterOfferContext ? "counter_negotiating" : "negotiating",
metadata: {
...record.metadata,
aiEmail: {
subject: emailResult.subject,
body: emailResult.body,
strategy: emailResult.strategy,
confidence: emailResult.confidenceLevel,
reasoning: emailResult.reasoning,
customTerms: emailResult.customTerms,
},
},
})
.eq("id", record.id);
if (updateError) {
throw updateError;
}
// Log the action - using provided client
await client
.from("audit_logs")
.insert({
debt_id: record.id,
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,
},
});
// Project has been disabled
return new Response(
JSON.stringify({
success: true,
strategy: emailResult.strategy,
projected_savings: emailResult.projectedSavings,
confidence: emailResult.confidenceLevel,
reasoning: emailResult.reasoning,
subject: emailResult.subject,
body: emailResult.body,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
JSON.stringify({
error: "Project Disabled",
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
}),
{
status: 503,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
});

View File

@@ -0,0 +1,494 @@
/*
# Email Sending Edge Function
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
*/
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
interface SendEmailRequest {
debtId: string;
}
interface UserProfile {
postmark_server_token?: string;
}
interface DebtRecord {
id: string;
vendor: string;
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;
};
}
// Send email using Postmark
async function sendEmailViaPostmark(
serverToken: string,
fromEmail: string,
toEmail: string,
subject: string,
body: string,
) {
const postmarkEndpoint = "https://api.postmarkapp.com/email";
const emailData = {
From: fromEmail,
To: toEmail,
Subject: subject,
TextBody: body,
MessageStream: "outbound",
};
const response = await fetch(postmarkEndpoint, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"X-Postmark-Server-Token": serverToken,
},
body: JSON.stringify(emailData),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Postmark API error: ${response.status} - ${errorData}`);
}
return await response.json();
}
// Extract variables from text in {{ variable }} format
function extractVariables(text: string): string[] {
const variableRegex = /\{\{([^}]+)\}\}/g;
const variables: string[] = [];
let match;
while ((match = variableRegex.exec(text)) !== null) {
variables.push(match[1].trim());
}
return [...new Set(variables)]; // Remove duplicates
}
// Replace variables in text with their values
function replaceVariables(
text: string,
variables: Record<string, string>,
): string {
return text.replace(/\{\{([^}]+)\}\}/g, (match, variableName) => {
const trimmedName = variableName.trim();
return variables[trimmedName] || ""; // Replace with empty string if variable not found
});
}
// 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"
const emailMatch = emailString.match(/<([^>]+)>/) ||
emailString.match(/([^\s<>]+@[^\s<>]+)/);
return emailMatch ? emailMatch[1] : emailString;
}
Deno.serve(async (req) => {
try {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
// Initialize Supabase client with auth context
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: req.headers.get("Authorization") ?? "" },
},
},
);
// Get the authenticated user
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Authorization header required" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const token = authHeader.replace("Bearer ", "");
const { data: userData } = await supabaseClient.auth.getUser(token);
const user = userData.user;
if (!user) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const { debtId }: SendEmailRequest = await req.json();
if (!debtId) {
return new Response(
JSON.stringify({ error: "Missing debtId" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch user profile with server token - using authenticated client
const { data: userProfile, error: userError } = await supabaseClient
.from("user_profiles")
.select("postmark_server_token")
.eq("user_id", user.id)
.single();
if (userError || !userProfile) {
return new Response(
JSON.stringify({ error: "User not found" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const userProfileData = userProfile as UserProfile;
if (!userProfileData.postmark_server_token) {
return new Response(
JSON.stringify({
error: "Postmark server token not configured",
requiresConfiguration: true,
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch debt record - RLS will ensure user can only access their own debts
const { data: debtRecord, error: debtError } = await supabaseClient
.from("debts")
.select("*")
.eq("id", debtId)
.eq("user_id", user.id)
.single();
if (debtError || !debtRecord) {
return new Response(
JSON.stringify({ error: "Debt record not found" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const debt = debtRecord as DebtRecord;
// Validate that negotiated plan exists
if (!debt.negotiated_plan || !debt.metadata?.aiEmail) {
return new Response(
JSON.stringify({
error: "No negotiated plan found. Please generate negotiation first.",
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Extract email details and process variables
const { subject: rawSubject, body: rawBody } = debt.metadata.aiEmail;
const fromEmail = debt.metadata?.toEmail || user.email;
if (!fromEmail) {
return new Response(
JSON.stringify({ error: "No valid sender email found" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Process email template and replace variables
const { processedSubject, processedBody, hasUnfilledVariables } =
await processEmailTemplate(
supabaseClient,
debtId,
rawSubject,
rawBody,
);
// Check if there are unfilled variables
if (hasUnfilledVariables) {
// console.warn("Email contains unfilled variables");
// 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) {
toEmail = extractEmailAddress(debt.metadata.fromEmail);
} else if (debt.vendor.includes("@")) {
toEmail = extractEmailAddress(debt.vendor);
} else {
// If vendor doesn't contain email, try to construct one
toEmail = `collections@${
debt.vendor.toLowerCase().replace(/\s+/g, "")
}.com`;
}
try {
// Send email via Postmark
const emailResult = await sendEmailViaPostmark(
userProfileData.postmark_server_token,
fromEmail,
toEmail,
subject,
body,
);
// 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: {
sentAt: new Date().toISOString(),
messageId: emailResult.MessageID,
to: toEmail,
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);
if (updateError) {
console.error("Error updating debt status:", updateError);
}
// Record the sent email in conversation history with processed content
await supabaseClient.from("conversation_messages").insert({
debt_id: debtId,
message_type: "negotiation_sent",
direction: "outbound",
subject: processedSubject, // Use processed subject with variables replaced
body: processedBody, // Use processed body with variables replaced
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({
success: true,
messageId: emailResult.MessageID,
sentTo: toEmail,
sentFrom: fromEmail,
subject: subject,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (emailError) {
console.error("Email sending error:", emailError);
const errorMessage = emailError instanceof Error
? emailError.message
: String(emailError);
// Log the failed attempt - using authenticated client
await supabaseClient
.from("audit_logs")
.insert({
debt_id: debtId,
action: "email_send_failed",
details: {
error: errorMessage,
to: toEmail,
from: fromEmail,
subject: subject,
},
});
return new Response(
JSON.stringify({
error: "Failed to send email",
details: errorMessage,
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
} catch (error) {
console.error("Send email function error:", error);
const errorMessage = error instanceof Error
? error.message
: "An unknown error occurred" +
(Deno.env.get("NODE_ENV") === "development"
? `: ${JSON.stringify(error)}`
: "");
return new Response(
JSON.stringify({ error: errorMessage }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
});

View File

@@ -9,8 +9,6 @@
- Ensures FDCPA compliance
*/
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
@@ -18,477 +16,21 @@ const corsHeaders = {
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
interface SendEmailRequest {
debtId: string;
}
interface UserProfile {
postmark_server_token?: string;
}
interface DebtRecord {
id: string;
vendor: string;
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;
};
}
// Send email using Postmark
async function sendEmailViaPostmark(
serverToken: string,
fromEmail: string,
toEmail: string,
subject: string,
body: string,
) {
const postmarkEndpoint = "https://api.postmarkapp.com/email";
const emailData = {
From: fromEmail,
To: toEmail,
Subject: subject,
TextBody: body,
MessageStream: "outbound",
};
const response = await fetch(postmarkEndpoint, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"X-Postmark-Server-Token": serverToken,
},
body: JSON.stringify(emailData),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Postmark API error: ${response.status} - ${errorData}`);
}
return await response.json();
}
// Extract variables from text in {{ variable }} format
function extractVariables(text: string): string[] {
const variableRegex = /\{\{([^}]+)\}\}/g;
const variables: string[] = [];
let match;
while ((match = variableRegex.exec(text)) !== null) {
variables.push(match[1].trim());
}
return [...new Set(variables)]; // Remove duplicates
}
// Replace variables in text with their values
function replaceVariables(
text: string,
variables: Record<string, string>,
): string {
return text.replace(/\{\{([^}]+)\}\}/g, (match, variableName) => {
const trimmedName = variableName.trim();
return variables[trimmedName] || ""; // Replace with empty string if variable not found
});
}
// 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"
const emailMatch = emailString.match(/<([^>]+)>/) ||
emailString.match(/([^\s<>]+@[^\s<>]+)/);
return emailMatch ? emailMatch[1] : emailString;
}
Deno.serve(async (req) => {
try {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
// Initialize Supabase client with auth context
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: req.headers.get("Authorization") ?? "" },
},
},
);
// Get the authenticated user
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Authorization header required" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const token = authHeader.replace("Bearer ", "");
const { data: userData } = await supabaseClient.auth.getUser(token);
const user = userData.user;
if (!user) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const { debtId }: SendEmailRequest = await req.json();
if (!debtId) {
return new Response(
JSON.stringify({ error: "Missing debtId" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch user profile with server token - using authenticated client
const { data: userProfile, error: userError } = await supabaseClient
.from("user_profiles")
.select("postmark_server_token")
.eq("user_id", user.id)
.single();
if (userError || !userProfile) {
return new Response(
JSON.stringify({ error: "User not found" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const userProfileData = userProfile as UserProfile;
if (!userProfileData.postmark_server_token) {
return new Response(
JSON.stringify({
error: "Postmark server token not configured",
requiresConfiguration: true,
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch debt record - RLS will ensure user can only access their own debts
const { data: debtRecord, error: debtError } = await supabaseClient
.from("debts")
.select("*")
.eq("id", debtId)
.eq("user_id", user.id)
.single();
if (debtError || !debtRecord) {
return new Response(
JSON.stringify({ error: "Debt record not found" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const debt = debtRecord as DebtRecord;
// Validate that negotiated plan exists
if (!debt.negotiated_plan || !debt.metadata?.aiEmail) {
return new Response(
JSON.stringify({
error: "No negotiated plan found. Please generate negotiation first.",
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Extract email details and process variables
const { subject: rawSubject, body: rawBody } = debt.metadata.aiEmail;
const fromEmail = debt.metadata?.toEmail || user.email;
if (!fromEmail) {
return new Response(
JSON.stringify({ error: "No valid sender email found" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Process email template and replace variables
const { processedSubject, processedBody, hasUnfilledVariables } =
await processEmailTemplate(
supabaseClient,
debtId,
rawSubject,
rawBody,
);
// Check if there are unfilled variables
if (hasUnfilledVariables) {
// console.warn("Email contains unfilled variables");
// 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) {
toEmail = extractEmailAddress(debt.metadata.fromEmail);
} else if (debt.vendor.includes("@")) {
toEmail = extractEmailAddress(debt.vendor);
} else {
// If vendor doesn't contain email, try to construct one
toEmail = `collections@${
debt.vendor.toLowerCase().replace(/\s+/g, "")
}.com`;
}
try {
// Send email via Postmark
const emailResult = await sendEmailViaPostmark(
userProfileData.postmark_server_token,
fromEmail,
toEmail,
subject,
body,
);
// 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: {
sentAt: new Date().toISOString(),
messageId: emailResult.MessageID,
to: toEmail,
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);
if (updateError) {
console.error("Error updating debt status:", updateError);
}
// Record the sent email in conversation history with processed content
await supabaseClient.from("conversation_messages").insert({
debt_id: debtId,
message_type: "negotiation_sent",
direction: "outbound",
subject: processedSubject, // Use processed subject with variables replaced
body: processedBody, // Use processed body with variables replaced
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({
success: true,
messageId: emailResult.MessageID,
sentTo: toEmail,
sentFrom: fromEmail,
subject: subject,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (emailError) {
console.error("Email sending error:", emailError);
const errorMessage = emailError instanceof Error
? emailError.message
: String(emailError);
// Log the failed attempt - using authenticated client
await supabaseClient
.from("audit_logs")
.insert({
debt_id: debtId,
action: "email_send_failed",
details: {
error: errorMessage,
to: toEmail,
from: fromEmail,
subject: subject,
},
});
return new Response(
JSON.stringify({
error: "Failed to send email",
details: errorMessage,
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
} catch (error) {
console.error("Send email function error:", error);
const errorMessage = error instanceof Error
? error.message
: "An unknown error occurred" +
(Deno.env.get("NODE_ENV") === "development"
? `: ${JSON.stringify(error)}`
: "");
return new Response(
JSON.stringify({ error: errorMessage }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
// Handle CORS preflight requests
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
});
// Project has been disabled
return new Response(
JSON.stringify({
error: "Project Disabled",
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
}),
{
status: 503,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
});

View 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" },
}
);
}
});

View File

@@ -1,170 +1,25 @@
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",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
// 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) => {
Deno.serve(async (req) => {
// Handle CORS preflight requests
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" },
}
);
// Project has been disabled
return new Response(
JSON.stringify({
error: "Project Disabled",
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
}),
{
status: 503,
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" },
}
);
}
});
);
});