mirror of
https://github.com/FranP-code/inbox-negotiator.git
synced 2025-10-13 00:42:26 +00:00
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:
@@ -12,7 +12,6 @@ import {
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { Debt } from "../lib/supabase";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
interface DebtTimelineProps {
|
||||
debt: Debt;
|
||||
|
||||
61
src/components/DisabledProject.tsx
Normal file
61
src/components/DisabledProject.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface DisabledProjectProps {
|
||||
showAuth?: boolean;
|
||||
}
|
||||
|
||||
export function DisabledProject({ showAuth = false }: DisabledProjectProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-background flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-orange-100 dark:bg-orange-900 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-8 h-8 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-foreground mb-4">
|
||||
Project Disabled
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-gray-600 dark:text-gray-300 mb-8">
|
||||
The project has been disabled (it was part of a hackathon). To enable it, please contact me.
|
||||
</p>
|
||||
|
||||
{showAuth && (
|
||||
<div className="space-y-4">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Authentication is still available:
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<a
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center px-6 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</a>
|
||||
<a
|
||||
href="/signup"
|
||||
className="inline-flex items-center justify-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Sign Up
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<a
|
||||
href="/"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
← Back to Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// Type definitions for Appwrite migration
|
||||
// This file no longer contains Supabase client - use appwrite.ts instead
|
||||
|
||||
// Dummy export to prevent build errors for disabled components
|
||||
export const supabase = null;
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
|
||||
@@ -1,584 +1,15 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import {
|
||||
createAppwriteAdmin,
|
||||
getUserIdByEmail,
|
||||
handleDatabaseError,
|
||||
} from "../../lib/appwrite-admin";
|
||||
import { generateObject } from "ai";
|
||||
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
||||
import { z } from "zod";
|
||||
import { DATABASE_ID, COLLECTIONS } from "../../lib/appwrite";
|
||||
import { ID, Query } from "appwrite";
|
||||
|
||||
// Schema for debt information extraction
|
||||
const debtSchema = z.object({
|
||||
amount: z.number().min(0).describe("The debt amount in dollars"),
|
||||
vendor: z.string().describe("The name or identifier of the vendor/creditor"),
|
||||
description: z.string().describe("Brief description of what the debt is for"),
|
||||
dueDate: z.string().optional().describe("Due date if mentioned (ISO format)"),
|
||||
isDebtCollection: z
|
||||
.boolean()
|
||||
.describe("Whether this appears to be a debt collection notice"),
|
||||
successfullyParsed: z
|
||||
.boolean()
|
||||
.describe("Whether the debt information was successfully parsed"),
|
||||
});
|
||||
|
||||
// Schema for opt-out detection
|
||||
const optOutSchema = z.object({
|
||||
isOptOut: z.boolean().describe(
|
||||
"Whether this email contains an opt-out request",
|
||||
),
|
||||
confidence: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.describe("Confidence level of the opt-out detection"),
|
||||
reason: z
|
||||
.string()
|
||||
.describe("Explanation of why this was classified as opt-out or not"),
|
||||
});
|
||||
|
||||
// Function to detect opt-out requests using AI
|
||||
async function detectOptOutWithAI(emailText: string, fromEmail: string) {
|
||||
try {
|
||||
const googleApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY ||
|
||||
import.meta.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||
if (!googleApiKey) {
|
||||
console.warn(
|
||||
"Google API key not configured, falling back to keyword detection",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await generateObject({
|
||||
model: createGoogleGenerativeAI({
|
||||
apiKey: googleApiKey,
|
||||
})("gemini-2.5-flash-preview-04-17"),
|
||||
system:
|
||||
`You are an expert at analyzing email content to detect opt-out requests.
|
||||
Analyze the email to determine if the sender is requesting to opt-out, unsubscribe,
|
||||
or stop receiving communications. Consider:
|
||||
- Explicit opt-out keywords (STOP, UNSUBSCRIBE, REMOVE, etc.)
|
||||
- Implicit requests to stop communication
|
||||
- Context and tone indicating unwillingness to continue correspondence
|
||||
- Legal language requesting cessation of contact
|
||||
Be conservative - only flag as opt-out if you're confident it's a genuine request.`,
|
||||
prompt: `Analyze this email for opt-out intent:
|
||||
|
||||
From: ${fromEmail}
|
||||
Content: ${emailText}`,
|
||||
schema: optOutSchema,
|
||||
});
|
||||
|
||||
return result.object;
|
||||
} catch (error) {
|
||||
console.error("AI opt-out detection error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to parse debt information using AI
|
||||
async function parseDebtWithAI(emailText: string, fromEmail: string) {
|
||||
try {
|
||||
// Check if Google API key is available
|
||||
const googleApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY ||
|
||||
import.meta.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||
if (!googleApiKey) {
|
||||
console.warn(
|
||||
"Google API key not configured, falling back to regex parsing",
|
||||
);
|
||||
throw new Error("No Google API key configured");
|
||||
}
|
||||
|
||||
const result = await generateObject({
|
||||
model: createGoogleGenerativeAI({
|
||||
apiKey: googleApiKey,
|
||||
})("gemini-2.5-flash-preview-04-17"),
|
||||
system:
|
||||
`You are an expert at analyzing debt collection and billing emails.
|
||||
Extract key debt information from the email content.
|
||||
Look for monetary amounts, creditor information, what the debt is for, and due dates.
|
||||
If this doesn't appear to be a legitimate debt or billing notice, set amount to 0.
|
||||
Be very accurate with amounts - look for dollar signs and numbers carefully.`,
|
||||
prompt: `Parse this email for debt information:
|
||||
|
||||
From: ${fromEmail}
|
||||
Content: ${emailText}`,
|
||||
schema: debtSchema,
|
||||
});
|
||||
|
||||
return result.object;
|
||||
} catch (error) {
|
||||
console.error("AI parsing error:", error);
|
||||
// Fallback to regex if AI fails
|
||||
const amountMatch = emailText.match(/\$(\d+(?:,\d{3})*(?:\.\d{2})?)/);
|
||||
return {
|
||||
amount: amountMatch ? parseFloat(amountMatch[1].replace(/,/g, "")) : 0,
|
||||
vendor: fromEmail || "unknown",
|
||||
description: "Failed to parse with AI - using regex fallback",
|
||||
isDebtCollection: amountMatch ? true : false,
|
||||
successfullyParsed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Function to increment email processing usage
|
||||
async function incrementEmailUsage(
|
||||
userId: string,
|
||||
appwriteAdmin: ReturnType<typeof createAppwriteAdmin>,
|
||||
) {
|
||||
try {
|
||||
// In Appwrite, we'll need to implement this differently since there are no stored procedures
|
||||
// For now, we'll implement a simple increment by finding the current month's usage and updating it
|
||||
|
||||
const currentDate = new Date();
|
||||
const monthYear = `${currentDate.getFullYear()}-${(currentDate.getMonth() + 1).toString().padStart(2, '0')}`;
|
||||
|
||||
// Get current usage for this month
|
||||
const response = await appwriteAdmin.databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMAIL_PROCESSING_USAGE,
|
||||
[Query.equal('user_id', userId), Query.equal('month_year', monthYear)]
|
||||
);
|
||||
|
||||
const existingUsage = response.documents[0];
|
||||
|
||||
if (existingUsage) {
|
||||
// Update existing usage
|
||||
await appwriteAdmin.databases.updateDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMAIL_PROCESSING_USAGE,
|
||||
existingUsage.$id,
|
||||
{
|
||||
emails_processed: existingUsage.emails_processed + 1,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Create new usage record
|
||||
await appwriteAdmin.databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMAIL_PROCESSING_USAGE,
|
||||
ID.unique(),
|
||||
{
|
||||
user_id: userId,
|
||||
month_year: monthYear,
|
||||
emails_processed: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error incrementing email usage:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if incoming email is a response to existing negotiation
|
||||
async function checkForExistingNegotiation(
|
||||
fromEmail: string,
|
||||
toEmail: string,
|
||||
appwriteAdmin: ReturnType<typeof createAppwriteAdmin>,
|
||||
) {
|
||||
try {
|
||||
// Look for debts where we've sent emails to this fromEmail and are awaiting response
|
||||
const response = await appwriteAdmin.databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.DEBTS,
|
||||
[Query.in('status', ['sent', 'awaiting_response', 'counter_negotiating']), Query.orderDesc('last_message_at')]
|
||||
);
|
||||
|
||||
// Find matching debts based on email metadata
|
||||
const matchingDebts = response.documents.filter(debt => {
|
||||
const metadata = debt.metadata as any;
|
||||
return metadata?.fromEmail === fromEmail &&
|
||||
metadata?.toEmail === toEmail;
|
||||
});
|
||||
|
||||
// Return the most recent debt that matches (already sorted by orderDesc in query)
|
||||
return matchingDebts.length > 0 ? matchingDebts[0] : null;
|
||||
} catch (error) {
|
||||
console.error("Error in checkForExistingNegotiation:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle response to existing negotiation
|
||||
async function handleNegotiationResponse(
|
||||
debt: any,
|
||||
emailData: any,
|
||||
appwriteAdmin: ReturnType<typeof createAppwriteAdmin>,
|
||||
) {
|
||||
try {
|
||||
const textBody = emailData.TextBody || emailData.HtmlBody || "";
|
||||
const fromEmail = emailData.FromFull?.Email || emailData.From || "unknown";
|
||||
const subject = emailData.Subject || "";
|
||||
const messageId = emailData.MessageID || `inbound-${Date.now()}`;
|
||||
|
||||
// First, record this message in the conversation
|
||||
await appwriteAdmin.databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CONVERSATION_MESSAGES,
|
||||
ID.unique(),
|
||||
{
|
||||
debt_id: debt.$id,
|
||||
message_type: "response_received",
|
||||
direction: "inbound",
|
||||
subject: subject,
|
||||
body: textBody,
|
||||
from_email: fromEmail,
|
||||
to_email: emailData.ToFull?.[0]?.Email || emailData.To || "",
|
||||
message_id: messageId,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
);
|
||||
|
||||
// Update debt conversation tracking
|
||||
await appwriteAdmin.databases.updateDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.DEBTS,
|
||||
debt.$id,
|
||||
{
|
||||
conversation_count: debt.conversation_count + 1,
|
||||
last_message_at: new Date().toISOString(),
|
||||
status: "counter_negotiating", // Temporary status while analyzing
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
);
|
||||
|
||||
// Call the analyze-response function
|
||||
const appwriteEndpoint = process.env.PUBLIC_APPWRITE_ENDPOINT ||
|
||||
import.meta.env.PUBLIC_APPWRITE_ENDPOINT;
|
||||
const appwriteProjectId = process.env.PUBLIC_APPWRITE_PROJECT_ID ||
|
||||
import.meta.env.PUBLIC_APPWRITE_PROJECT_ID;
|
||||
const appwriteApiKey = process.env.APPWRITE_API_KEY ||
|
||||
import.meta.env.APPWRITE_API_KEY;
|
||||
|
||||
if (appwriteEndpoint && appwriteProjectId && appwriteApiKey) {
|
||||
const analyzeUrl = `${appwriteEndpoint}/functions/v1/analyze-response`;
|
||||
|
||||
try {
|
||||
const response = await fetch(analyzeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Appwrite-Project": appwriteProjectId,
|
||||
"X-Appwrite-Key": appwriteApiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
debtId: debt.$id,
|
||||
fromEmail,
|
||||
subject,
|
||||
body: textBody,
|
||||
messageId: messageId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log("Response analysis completed:", result);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: "Negotiation response processed",
|
||||
analysis: result.analysis,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
"Error calling analyze-response function:",
|
||||
await response.text(),
|
||||
);
|
||||
}
|
||||
} catch (analyzeError) {
|
||||
console.error("Error calling analyze-response function:", analyzeError);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: just log the response and mark for manual review
|
||||
await appwriteAdmin.databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.AUDIT_LOGS,
|
||||
ID.unique(),
|
||||
{
|
||||
debt_id: debt.$id,
|
||||
action: "response_received_fallback",
|
||||
details: {
|
||||
fromEmail,
|
||||
subject,
|
||||
bodyPreview: textBody.substring(0, 200),
|
||||
requiresManualReview: true,
|
||||
},
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "Response logged" }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error handling negotiation response:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to process negotiation response" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
// Create admin client for webhook operations
|
||||
let appwriteAdmin;
|
||||
try {
|
||||
appwriteAdmin = createAppwriteAdmin();
|
||||
} catch (configError) {
|
||||
console.error("Appwrite admin configuration error:", configError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Server configuration error" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
|
||||
// Validate essential webhook data
|
||||
if (!data.TextBody && !data.HtmlBody) {
|
||||
return new Response(JSON.stringify({ error: "No email content found" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Check for opt-out keywords
|
||||
const textBody = data.TextBody || data.HtmlBody || "";
|
||||
const fromEmail = data.FromFull?.Email || data.From || "unknown";
|
||||
const toEmail = data.ToFull?.[0]?.Email || data.To || "";
|
||||
|
||||
// Find the user who should receive this debt
|
||||
const userId = await getUserIdByEmail(toEmail, appwriteAdmin);
|
||||
if (!userId) {
|
||||
console.warn(`No user found for email: ${toEmail}`);
|
||||
return new Response("No matching user found", { status: 200 });
|
||||
}
|
||||
|
||||
// Check if this is a response to an existing negotiation
|
||||
const existingDebt = await checkForExistingNegotiation(
|
||||
fromEmail,
|
||||
toEmail,
|
||||
appwriteAdmin,
|
||||
);
|
||||
|
||||
console.log({ existingDebt, fromEmail, toEmail });
|
||||
if (existingDebt) {
|
||||
console.log(
|
||||
`Found existing negotiation for debt ${existingDebt.$id}, analyzing response...`,
|
||||
);
|
||||
return await handleNegotiationResponse(existingDebt, data, appwriteAdmin);
|
||||
}
|
||||
|
||||
// Increment email processing usage
|
||||
await incrementEmailUsage(userId, appwriteAdmin);
|
||||
|
||||
// Check for opt-out using AI
|
||||
const optOutDetection = await detectOptOutWithAI(textBody, fromEmail);
|
||||
let hasOptOut = false;
|
||||
|
||||
if (optOutDetection) {
|
||||
hasOptOut = optOutDetection.isOptOut && optOutDetection.confidence > 0.7;
|
||||
console.log(
|
||||
`AI opt-out detection: ${hasOptOut} (confidence: ${optOutDetection.confidence})`,
|
||||
);
|
||||
} else {
|
||||
// Fallback to keyword matching if AI is unavailable
|
||||
const optOutKeywords = ["STOP", "UNSUBSCRIBE", "OPT-OUT", "REMOVE"];
|
||||
hasOptOut = optOutKeywords.some((keyword) =>
|
||||
textBody.toUpperCase().includes(keyword)
|
||||
);
|
||||
console.log("Using fallback keyword opt-out detection");
|
||||
}
|
||||
|
||||
if (hasOptOut) {
|
||||
// Log opt-out and don't process further
|
||||
try {
|
||||
await appwriteAdmin.databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.DEBTS,
|
||||
ID.unique(),
|
||||
{
|
||||
user_id: userId,
|
||||
vendor: fromEmail,
|
||||
amount: 0,
|
||||
raw_email: textBody,
|
||||
status: "opted_out",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error logging opt-out:", error);
|
||||
const errorInfo = handleDatabaseError(error);
|
||||
return new Response(JSON.stringify({ error: errorInfo.message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("Opt-out processed", { status: 200 });
|
||||
}
|
||||
|
||||
// Parse debt information using AI
|
||||
const debtInfo = await parseDebtWithAI(textBody, fromEmail);
|
||||
|
||||
if (!debtInfo || !debtInfo.successfullyParsed) {
|
||||
console.warn("Failed to parse debt information");
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to parse debt information" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Insert debt record with AI-extracted information
|
||||
let insertedDebt;
|
||||
try {
|
||||
insertedDebt = await appwriteAdmin.databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.DEBTS,
|
||||
ID.unique(),
|
||||
{
|
||||
user_id: userId,
|
||||
vendor: debtInfo.vendor,
|
||||
amount: debtInfo.amount,
|
||||
raw_email: textBody,
|
||||
status: "received",
|
||||
description: debtInfo.description,
|
||||
due_date: debtInfo.dueDate,
|
||||
conversation_count: 1,
|
||||
last_message_at: new Date().toISOString(),
|
||||
negotiation_round: 1,
|
||||
projected_savings: 0,
|
||||
metadata: {
|
||||
isDebtCollection: debtInfo.isDebtCollection,
|
||||
subject: data.Subject,
|
||||
fromEmail: fromEmail,
|
||||
toEmail: toEmail,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
|
||||
// Record the initial debt email as the first conversation message
|
||||
await appwriteAdmin.databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CONVERSATION_MESSAGES,
|
||||
ID.unique(),
|
||||
{
|
||||
debt_id: insertedDebt.$id,
|
||||
message_type: "initial_debt",
|
||||
direction: "inbound",
|
||||
subject: data.Subject,
|
||||
body: textBody,
|
||||
from_email: fromEmail,
|
||||
to_email: toEmail,
|
||||
message_id: data.MessageID || `initial-${Date.now()}`,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
} catch (insertError) {
|
||||
console.error("Error inserting debt:", insertError);
|
||||
const errorInfo = handleDatabaseError(insertError);
|
||||
|
||||
// Project has been disabled
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: errorInfo.message,
|
||||
details: errorInfo.originalError,
|
||||
error: "Project Disabled",
|
||||
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Log the email receipt
|
||||
await appwriteAdmin.databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.AUDIT_LOGS,
|
||||
ID.unique(),
|
||||
{
|
||||
debt_id: insertedDebt.$id,
|
||||
action: "email_received",
|
||||
details: {
|
||||
vendor: debtInfo.vendor,
|
||||
amount: debtInfo.amount,
|
||||
subject: data.Subject,
|
||||
aiParsed: true,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
|
||||
// Trigger negotiation function if this is a legitimate debt
|
||||
if (debtInfo.amount > 0 && debtInfo.isDebtCollection) {
|
||||
// Access environment variables through Astro runtime
|
||||
const appwriteEndpoint = process.env.PUBLIC_APPWRITE_ENDPOINT ||
|
||||
import.meta.env.PUBLIC_APPWRITE_ENDPOINT;
|
||||
const appwriteProjectId = process.env.PUBLIC_APPWRITE_PROJECT_ID ||
|
||||
import.meta.env.PUBLIC_APPWRITE_PROJECT_ID;
|
||||
const appwriteApiKey = process.env.APPWRITE_API_KEY ||
|
||||
import.meta.env.APPWRITE_API_KEY;
|
||||
|
||||
if (appwriteEndpoint && appwriteProjectId && appwriteApiKey) {
|
||||
const negotiateUrl = `${appwriteEndpoint}/functions/v1/negotiate`;
|
||||
|
||||
try {
|
||||
await fetch(negotiateUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Appwrite-Project": appwriteProjectId,
|
||||
"X-Appwrite-Key": appwriteApiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ record: insertedDebt }),
|
||||
});
|
||||
} catch (negotiateError) {
|
||||
console.error("Error triggering negotiation:", negotiateError);
|
||||
// Don't fail the webhook if negotiation fails
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Appwrite environment variables not configured for negotiation trigger",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response("OK", { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Postmark webhook error:", error);
|
||||
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import "@/styles/globals.css";
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { Configuration as ConfigComponent } from "../components/Configuration";
|
||||
import { DisabledProject } from "../components/DisabledProject";
|
||||
import { Navbar } from "../components/Navbar";
|
||||
import { AuthGuard } from "../components/AuthGuard";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
@@ -11,7 +11,7 @@ import { Toaster } from "../components/ui/sonner";
|
||||
<Navbar client:load />
|
||||
|
||||
<AuthGuard requireAuth={true} client:load>
|
||||
<ConfigComponent client:load />
|
||||
<DisabledProject client:load />
|
||||
</AuthGuard>
|
||||
<Toaster client:load />
|
||||
</Layout>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import "@/styles/globals.css";
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { Dashboard } from "../components/Dashboard";
|
||||
import { DisabledProject } from "../components/DisabledProject";
|
||||
import { AuthGuard } from "../components/AuthGuard";
|
||||
import { Navbar } from "../components/Navbar";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
@@ -11,7 +11,7 @@ import { Toaster } from "../components/ui/sonner";
|
||||
<Navbar client:load />
|
||||
|
||||
<AuthGuard requireAuth={true} client:load>
|
||||
<Dashboard client:load />
|
||||
<DisabledProject client:load />
|
||||
</AuthGuard>
|
||||
<Toaster client:load />
|
||||
</Layout>
|
||||
|
||||
@@ -1,274 +1,11 @@
|
||||
---
|
||||
import "@/styles/globals.css";
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { DisabledProject } from "../components/DisabledProject";
|
||||
import { Navbar } from "../components/Navbar";
|
||||
---
|
||||
|
||||
<Layout title="InboxNegotiator - AI-Powered Debt Resolution">
|
||||
<Navbar client:load />
|
||||
|
||||
<main
|
||||
class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50 dark:from-gray-900 dark:via-background dark:to-gray-800"
|
||||
>
|
||||
<!-- Hero Section -->
|
||||
<section class="relative overflow-hidden">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-16">
|
||||
<div class="text-center">
|
||||
<h1
|
||||
class="text-4xl md:text-6xl font-bold text-gray-900 dark:text-foreground mb-6"
|
||||
>
|
||||
AI-Powered
|
||||
<span
|
||||
class="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600 dark:from-blue-400 dark:to-purple-400"
|
||||
>
|
||||
Debt Resolution
|
||||
</span>
|
||||
</h1>
|
||||
<p
|
||||
class="text-xl text-gray-600 dark:text-gray-300 mb-8 max-w-3xl mx-auto"
|
||||
>
|
||||
Forward your debt emails and let our AI negotiate FDCPA-compliant
|
||||
payment plans automatically. Save time, reduce stress, and
|
||||
potentially save thousands on your debts.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="/signup"
|
||||
class="inline-flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Get Started Free
|
||||
</a>
|
||||
<a
|
||||
href="#how-it-works"
|
||||
class="inline-flex items-center justify-center px-8 py-3 border border-gray-300 dark:border-gray-600 text-base font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="how-it-works" class="py-16 bg-white dark:bg-background">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2
|
||||
class="text-3xl font-bold text-gray-900 dark:text-foreground mb-4"
|
||||
>
|
||||
How It Works
|
||||
</h2>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-300">
|
||||
Simple, automated, and compliant debt resolution
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-16 h-16 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-xl font-semibold text-gray-900 dark:text-foreground mb-2"
|
||||
>
|
||||
Forward Emails
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
Simply forward your debt collection emails to our secure
|
||||
processing address.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-16 h-16 bg-purple-100 dark:bg-purple-900 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-purple-600 dark:text-purple-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-xl font-semibold text-gray-900 dark:text-foreground mb-2"
|
||||
>
|
||||
AI Analysis
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
Our AI analyzes the debt and generates FDCPA-compliant negotiation
|
||||
strategies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-16 h-16 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-green-600 dark:text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-xl font-semibold text-gray-900 dark:text-foreground mb-2"
|
||||
>
|
||||
Track Progress
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
Monitor negotiations in real-time and track your potential
|
||||
savings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Benefits Section -->
|
||||
<section class="py-16 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2
|
||||
class="text-3xl font-bold text-gray-900 dark:text-foreground mb-4"
|
||||
>
|
||||
Why Choose InboxNegotiator?
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div
|
||||
class="bg-white dark:bg-card p-6 rounded-lg shadow-sm border dark:border-border"
|
||||
>
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900 dark:text-foreground mb-2"
|
||||
>
|
||||
FDCPA Compliant
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
All responses follow Fair Debt Collection Practices Act
|
||||
guidelines.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-card p-6 rounded-lg shadow-sm border dark:border-border"
|
||||
>
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900 dark:text-foreground mb-2"
|
||||
>
|
||||
Save Money
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
Potentially reduce your debt by up to 40% through strategic
|
||||
negotiations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-card p-6 rounded-lg shadow-sm border dark:border-border"
|
||||
>
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900 dark:text-foreground mb-2"
|
||||
>
|
||||
Real-time Updates
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
Track your negotiations with live dashboard updates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-card p-6 rounded-lg shadow-sm border dark:border-border"
|
||||
>
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900 dark:text-foreground mb-2"
|
||||
>
|
||||
Secure & Private
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
Your financial information is encrypted and protected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="py-16 bg-blue-600 dark:bg-blue-700">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 class="text-3xl font-bold text-white mb-4">
|
||||
Ready to Take Control of Your Debt?
|
||||
</h2>
|
||||
<p class="text-xl text-blue-100 dark:text-blue-200 mb-8">
|
||||
Join thousands who have successfully negotiated their debts with AI
|
||||
assistance.
|
||||
</p>
|
||||
<a
|
||||
href="/signup"
|
||||
class="inline-flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-blue-600 bg-white hover:bg-gray-50 dark:text-blue-700 dark:bg-gray-100 dark:hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Start Your Free Account
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-900 dark:bg-gray-950 text-white py-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center">
|
||||
<div class="flex items-center justify-center gap-3 mb-4">
|
||||
<svg
|
||||
class="w-8 h-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-xl font-bold">InboxNegotiator</span>
|
||||
</div>
|
||||
<p class="text-gray-400 dark:text-gray-500 mb-4">
|
||||
AI-powered debt resolution platform
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-600">
|
||||
© 2025 InboxNegotiator. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<DisabledProject showAuth={true} client:load />
|
||||
</Layout>
|
||||
|
||||
854
supabase/functions/analyze-response/index-original.ts
Normal file
854
supabase/functions/analyze-response/index-original.ts
Normal 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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
// Project has been disabled
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Missing required fields: debtId, fromEmail, body",
|
||||
error: "Project Disabled",
|
||||
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
235
supabase/functions/approve-debt/index-original.ts
Normal file
235
supabase/functions/approve-debt/index-original.ts
Normal 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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 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") {
|
||||
// Project has been disabled
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Debt is not in negotiating status",
|
||||
currentStatus: debt.status,
|
||||
error: "Project Disabled",
|
||||
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
status: 503,
|
||||
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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
635
supabase/functions/negotiate/index-original.ts
Normal file
635
supabase/functions/negotiate/index-original.ts
Normal 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" } },
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
// Project has been disabled
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Missing record or user_id for service call",
|
||||
error: "Project Disabled",
|
||||
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
status: 503,
|
||||
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" } },
|
||||
);
|
||||
}
|
||||
|
||||
494
supabase/functions/send-email/index-original.ts
Normal file
494
supabase/functions/send-email/index-original.ts
Normal 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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
return new Response("ok", { 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) {
|
||||
// Project has been disabled
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Postmark server token not configured",
|
||||
requiresConfiguration: true,
|
||||
error: "Project Disabled",
|
||||
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
status: 503,
|
||||
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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
170
supabase/functions/test-extraction/index-original.ts
Normal file
170
supabase/functions/test-extraction/index-original.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
import { generateObject } from "https://esm.sh/ai@3.4.7";
|
||||
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
|
||||
import { z } from "https://esm.sh/zod@3.22.4";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
// Same schema as analyze-response
|
||||
const responseAnalysisSchema = z.object({
|
||||
intent: z.enum([
|
||||
"acceptance",
|
||||
"rejection",
|
||||
"counter_offer",
|
||||
"request_info",
|
||||
"unclear",
|
||||
]).describe("The primary intent of the response"),
|
||||
sentiment: z.enum(["positive", "negative", "neutral"])
|
||||
.describe("Overall sentiment of the response"),
|
||||
confidence: z.number().min(0).max(1)
|
||||
.describe("Confidence in the intent classification"),
|
||||
extractedTerms: z.object({
|
||||
proposedAmount: z.number().optional().describe(
|
||||
"Any amount mentioned in response",
|
||||
),
|
||||
proposedPaymentPlan: z.string().optional().describe(
|
||||
"Payment plan details if mentioned",
|
||||
),
|
||||
paymentTerms: z.object({
|
||||
monthlyAmount: z.number().optional().describe("Monthly payment amount"),
|
||||
numberOfPayments: z.number().optional().describe(
|
||||
"Number of payments/installments",
|
||||
),
|
||||
totalAmount: z.number().optional().describe("Total amount to be paid"),
|
||||
interestRate: z.number().optional().describe(
|
||||
"Interest rate if applicable",
|
||||
),
|
||||
paymentFrequency: z.string().optional().describe(
|
||||
"Payment frequency (monthly, weekly, etc.)",
|
||||
),
|
||||
}).optional().describe("Structured payment plan terms"),
|
||||
deadline: z.string().optional().describe("Any deadline mentioned"),
|
||||
conditions: z.array(z.string()).optional().describe(
|
||||
"Any conditions or requirements mentioned",
|
||||
),
|
||||
}).describe("Key terms extracted from the response"),
|
||||
reasoning: z.string().describe("Explanation of the analysis"),
|
||||
suggestedNextAction: z.enum([
|
||||
"accept_offer",
|
||||
"send_counter",
|
||||
"request_clarification",
|
||||
"escalate_to_user",
|
||||
"mark_settled",
|
||||
]).describe("Recommended next action"),
|
||||
requiresUserReview: z.boolean().describe(
|
||||
"Whether this response needs human review",
|
||||
),
|
||||
});
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { testEmail } = await req.json();
|
||||
|
||||
if (!testEmail) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "testEmail is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
|
||||
|
||||
if (!googleApiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Google API key not configured" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Testing extraction with email:", testEmail);
|
||||
|
||||
const result = await generateObject({
|
||||
model: createGoogleGenerativeAI({
|
||||
apiKey: googleApiKey,
|
||||
})("gemini-2.5-flash-preview-04-17"),
|
||||
system: `You are an expert financial analyst specializing in debt collection and negotiation responses.
|
||||
Your job is to carefully analyze creditor responses and extract ALL financial terms mentioned.
|
||||
|
||||
CRITICAL: Always extract financial information when present. Look for:
|
||||
|
||||
AMOUNTS:
|
||||
- Any dollar amounts mentioned ($1,000, $500, etc.)
|
||||
- Settlement offers or counter-offers
|
||||
- Monthly payment amounts
|
||||
- Total payment amounts
|
||||
|
||||
PAYMENT PLANS:
|
||||
- Monthly payment amounts (e.g., "$200 per month", "$150/month")
|
||||
- Number of payments/installments (e.g., "12 months", "24 payments")
|
||||
- Payment frequency (monthly, weekly, bi-weekly)
|
||||
- Total amounts for payment plans
|
||||
- Interest rates if mentioned
|
||||
|
||||
EXTRACT EVERYTHING: Even if amounts seem obvious, always include them in extractedTerms.`,
|
||||
prompt: `Analyze this test email and extract ALL financial terms:
|
||||
|
||||
EMAIL: ${testEmail}
|
||||
|
||||
EXTRACTION REQUIREMENTS:
|
||||
1. Find ANY dollar amounts mentioned in the email
|
||||
2. Look for payment plan details (monthly amounts, number of payments)
|
||||
3. Identify payment frequency (monthly, weekly, etc.)
|
||||
4. Extract total amounts if mentioned
|
||||
5. Note any interest rates or fees
|
||||
6. Capture all conditions and requirements
|
||||
|
||||
EXAMPLES OF WHAT TO EXTRACT:
|
||||
- "We can accept $250 per month" → monthlyAmount: 250
|
||||
- "for 18 months" → numberOfPayments: 18
|
||||
- "totaling $4,500" → totalAmount: 4500
|
||||
- "settlement of $3,200" → proposedAmount: 3200
|
||||
- "monthly payments" → paymentFrequency: "monthly"
|
||||
|
||||
Be thorough and extract ALL financial information present in the email.`,
|
||||
schema: responseAnalysisSchema,
|
||||
});
|
||||
|
||||
console.log("AI Analysis Result:", JSON.stringify(result.object, null, 2));
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
analysis: result.object,
|
||||
extractedTerms: result.object.extractedTerms,
|
||||
debug: {
|
||||
emailLength: testEmail.length,
|
||||
hasGoogleAPI: !!googleApiKey,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in test-extraction function:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Internal server error",
|
||||
details: error.message
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
// Project has been disabled
|
||||
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
|
||||
error: "Project Disabled",
|
||||
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
status: 503,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user