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

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

View File

@@ -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;

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

View File

@@ -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;

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,854 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { generateObject } from "https://esm.sh/ai@3.4.7";
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
import { z } from "https://esm.sh/zod@3.22.4";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
// Schema for AI response analysis
const responseAnalysisSchema = z.object({
intent: z.enum([
"acceptance",
"rejection",
"counter_offer",
"request_info",
"unclear",
])
.describe("The primary intent of the response"),
sentiment: z.enum(["positive", "negative", "neutral"])
.describe("Overall sentiment of the response"),
confidence: z.number().min(0).max(1)
.describe("Confidence in the intent classification"),
extractedTerms: z.object({
proposedAmount: z.number().optional().describe(
"Any amount mentioned in response",
),
proposedPaymentPlan: z.string().optional().describe(
"Payment plan details if mentioned",
),
paymentTerms: z.object({
monthlyAmount: z.number().optional().describe("Monthly payment amount"),
numberOfPayments: z.number().optional().describe(
"Number of payments/installments",
),
totalAmount: z.number().optional().describe("Total amount to be paid"),
interestRate: z.number().optional().describe(
"Interest rate if applicable",
),
paymentFrequency: z.string().optional().describe(
"Payment frequency (monthly, weekly, etc.)",
),
}).optional().describe("Structured payment plan terms"),
deadline: z.string().optional().describe("Any deadline mentioned"),
conditions: z.array(z.string()).optional().describe(
"Any conditions or requirements mentioned",
),
}).describe("Key terms extracted from the response"),
reasoning: z.string().describe("Explanation of the analysis"),
suggestedNextAction: z.enum([
"accept_offer",
"send_counter",
"request_clarification",
"escalate_to_user",
"mark_settled",
]).describe("Recommended next action"),
requiresUserReview: z.boolean().describe(
"Whether this response needs human review",
),
});
interface EmailResponseData {
debtId: string;
fromEmail: string;
subject: string;
body: string;
messageId?: string;
}
// Retrieve full conversation history for context
async function getConversationHistory(
supabaseClient: any,
debtId: string,
) {
try {
const { data: messages, error } = await supabaseClient
.from("conversation_messages")
.select("*")
.eq("debt_id", debtId)
.order("created_at", { ascending: true });
if (error) {
console.error("Error fetching conversation history:", error);
return [];
}
return messages || [];
} catch (error) {
console.error("Error in getConversationHistory:", error);
return [];
}
}
// AI-powered response analysis
async function analyzeEmailResponse(
supabaseClient: any,
debtId: string,
fromEmail: string,
subject: string,
body: string,
originalNegotiation?: any,
) {
try {
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
if (!googleApiKey) {
console.warn("Google API key not configured, using fallback analysis");
return getFallbackAnalysis(body);
}
console.log("Analyzing email with AI:", {
fromEmail,
subject,
bodyLength: body.length,
});
// Get full conversation history for better context
const conversationHistory = await getConversationHistory(
supabaseClient,
debtId,
);
console.log({
debtId,
fromEmail,
subject,
body,
originalNegotiation,
conversationHistoryLength: conversationHistory.length,
});
const system =
`You are an expert financial analysis AI. Your sole function is to meticulously analyze creditor emails and populate a structured JSON object that conforms to the provided schema.
Your entire output MUST be a single, valid JSON object. Do not include any markdown, explanations, or conversational text outside of the JSON structure itself.
--- FIELD-BY-FIELD INSTRUCTIONS ---
1. **intent**: Classify the creditor's primary intent.
- "acceptance": Clear agreement to our original proposal.
- "rejection": Clear refusal of our proposal without offering new terms.
- "counter_offer": Proposes ANY new financial terms (different amount, payment plan, etc.). This is the most common intent besides acceptance.
- "request_info": Asks for more information (e.g., "Can you provide proof of hardship?").
- "unclear": The purpose of the email cannot be determined.
2. **sentiment**: Classify the emotional tone of the email.
- "positive": Cooperative, polite, agreeable language.
- "negative": Hostile, demanding, or threatening language.
- "neutral": Strictly professional, factual, and devoid of emotional language.
3. **confidence**: Provide a score from 0.0 to 1.0 for your "intent" classification. (e.g., 0.95).
4. **extractedTerms**:
- **proposedAmount**: Extract a single, lump-sum settlement amount if offered. If the offer is a payment plan, this field should be null.
- **proposedPaymentPlan**: Capture the payment plan offer as a descriptive string, exactly as the creditor states it. e.g., "$100 a month for 12 months". If no plan is mentioned, this is null.
- **paymentTerms**: If a payment plan is mentioned, break it down into its structured components here. If no plan is mentioned, this entire object should be null.
- **monthlyAmount**: The specific amount for each payment.
- **numberOfPayments**: The number of installments.
- **totalAmount**: The total payout of the plan, ONLY if explicitly stated (e.g., "...totaling $1200").
- **interestRate**: The interest rate as a number (e.g., for "5% interest", extract 5).
- **paymentFrequency**: The frequency (e.g., "monthly", "weekly", "bi-weekly").
- **deadline**: Extract any specific date or timeframe for action (e.g., "by June 30th", "within 10 days").
- **conditions**: Extract all non-financial requirements as an array of strings. Example: ["Payment must be via certified funds", "A settlement agreement must be signed"]. If none, use an empty array [].
5. **reasoning**: Briefly explain the logic behind your "intent" and "suggestedNextAction" classifications, referencing parts of the email.
6. **suggestedNextAction**: Recommend the most logical business action.
- "accept_offer": The creditor's offer matches or is better than our goal.
- "send_counter": The creditor made a counter-offer that we should negotiate.
- "request_clarification": The email is ambiguous or missing key information.
- "escalate_to_user": The response is hostile, contains legal threats, is complex, or requires a human decision.
- "mark_settled": The email confirms the debt is fully settled and no further action is needed.
7. **requiresUserReview**: Set to 'true' if intent is "unclear", sentiment is "negative", confidence is below 0.85, the email contains unusual legal language, the "suggestedNextAction" is "escalate_to_user", OR if the message is vague/lacks specific financial terms (e.g., "think of a better offer", "we need more", etc.). Only set to 'false' for clear, specific counter-offers with concrete terms.`;
// Build conversation history context
// const conversationContext = conversationHistory.length > 0
// ? `--- FULL CONVERSATION HISTORY ---
// ${
// conversationHistory.map((msg, index) =>
// `${
// index + 1
// }. ${msg.direction.toUpperCase()} - ${msg.message_type} (${
// new Date(msg.created_at).toLocaleDateString()
// })
// Subject: ${msg.subject || "N/A"}
// Body: ${msg.body.substring(0, 500)}${msg.body.length > 500 ? "..." : ""}
// ${msg.ai_analysis ? `AI Analysis: ${JSON.stringify(msg.ai_analysis)}` : ""}
// ---`
// ).join("\n")
// }
// `
// : "";
const conversationContext = conversationHistory.length > 0
? `--- FULL CONVERSATION HISTORY ---
${
conversationHistory.map((msg, index) =>
`${
index + 1
}. ${msg.direction.toUpperCase()} - ${msg.message_type} (${
new Date(msg.created_at).toLocaleDateString()
})
Subject: ${msg.subject || "N/A"}
Body: ${msg.body}
${msg.ai_analysis ? `AI Analysis: ${JSON.stringify(msg.ai_analysis)}` : ""}
---`
).join("\n")
}
`
: "";
const prompt =
`Analyze the following email and extract the financial details and intent, populating the JSON object according to your system instructions.
--- CURRENT EMAIL TO ANALYZE ---
From: ${fromEmail}
Subject: ${subject}
Body: ${body}
${conversationContext}
${
originalNegotiation
? `--- MOST RECENT NEGOTIATION CONTEXT ---
Our Negotiation Strategy: ${originalNegotiation.strategy}
Our Proposed Amount: $${originalNegotiation.proposedAmount || "N/A"}
Our Proposed Terms: ${originalNegotiation.terms || "N/A"}
Our Reasoning: ${originalNegotiation.reasoning || "N/A"}
Our Latest Email Body: ${originalNegotiation.body || "N/A"}
`
: ""
}`;
console.log("AI Analysis System:", system);
console.log("AI Analysis Prompt:", prompt);
const result = await generateObject({
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system,
prompt,
schema: responseAnalysisSchema,
});
console.log("AI Analysis Result:", JSON.stringify(result.object, null, 2));
console.log(
"Extracted Terms:",
JSON.stringify(result.object.extractedTerms, null, 2),
);
return result.object;
} catch (error) {
console.error("AI response analysis error:", error);
console.log("Falling back to regex-based analysis");
return getFallbackAnalysis(body);
}
}
// Calculate financial outcome when offer is accepted
function calculateFinancialOutcome(debt: any, analysis: any): any {
try {
const originalAmount = debt.amount || 0;
let acceptedAmount = originalAmount;
let paymentStructure = null;
let financialBenefit = null;
// Try to extract accepted amount from AI analysis
if (analysis.extractedTerms?.proposedAmount) {
acceptedAmount = analysis.extractedTerms.proposedAmount;
} else if (analysis.extractedTerms?.paymentTerms?.totalAmount) {
acceptedAmount = analysis.extractedTerms.paymentTerms.totalAmount;
} else if (debt.metadata?.prospectedSavings?.amount) {
// Fall back to original negotiation terms if no specific amount mentioned
acceptedAmount = originalAmount - debt.metadata.prospectedSavings.amount;
}
// Analyze payment structure if present
if (analysis.extractedTerms?.paymentTerms) {
const terms = analysis.extractedTerms.paymentTerms;
paymentStructure = {
type: "installment_plan",
monthlyAmount: terms.monthlyAmount,
numberOfPayments: terms.numberOfPayments,
totalAmount: terms.totalAmount || acceptedAmount,
frequency: terms.paymentFrequency || "monthly",
interestRate: terms.interestRate || 0,
};
// Calculate time value and cash flow benefits
if (terms.monthlyAmount && terms.numberOfPayments) {
const totalPayments = terms.monthlyAmount * terms.numberOfPayments;
const timeToComplete = terms.numberOfPayments; // in months
financialBenefit = {
type: "payment_restructuring",
principalReduction: Math.max(0, originalAmount - totalPayments),
cashFlowRelief: {
monthlyReduction: originalAmount - terms.monthlyAmount,
extendedTermMonths: timeToComplete,
totalCashFlowBenefit: (originalAmount - terms.monthlyAmount) *
timeToComplete,
},
timeValueBenefit: calculateTimeValueBenefit(
originalAmount,
terms.monthlyAmount,
timeToComplete,
),
};
}
}
// Calculate actual savings (principal reduction)
const actualSavings = Math.max(0, originalAmount - acceptedAmount);
// Determine the primary financial benefit
if (actualSavings > 0) {
financialBenefit = {
type: "principal_reduction",
amount: actualSavings,
percentage: (actualSavings / originalAmount * 100).toFixed(2),
description: `${
((actualSavings / originalAmount) * 100).toFixed(1)
}% principal reduction`,
};
} else if (paymentStructure && paymentStructure.monthlyAmount) {
// No principal reduction but payment restructuring
const monthlyReduction = originalAmount - paymentStructure.monthlyAmount;
financialBenefit = {
type: "payment_restructuring",
monthlyReduction: monthlyReduction,
extendedTermMonths: paymentStructure.numberOfPayments,
description:
`Payment restructured to $${paymentStructure.monthlyAmount}/month over ${paymentStructure.numberOfPayments} months`,
cashFlowBenefit: monthlyReduction > 0
? `$${monthlyReduction}/month cash flow relief`
: "Extended payment terms",
};
}
console.log(
`Financial outcome: Original: $${originalAmount}, Accepted: $${acceptedAmount}, Savings: $${actualSavings}`,
);
return {
actualSavings,
acceptedAmount,
paymentStructure,
financialBenefit,
originalAmount,
};
} catch (error) {
console.error("Error calculating financial outcome:", error);
// Return basic fallback
return {
actualSavings: debt.projected_savings || 0,
acceptedAmount: debt.amount,
paymentStructure: null,
financialBenefit: null,
originalAmount: debt.amount,
};
}
}
// Calculate time value benefit of extended payment terms
function calculateTimeValueBenefit(
originalAmount: number,
monthlyPayment: number,
months: number,
): any {
// Simple present value calculation assuming 5% annual discount rate
const monthlyRate = 0.05 / 12;
let presentValue = 0;
for (let i = 1; i <= months; i++) {
presentValue += monthlyPayment / Math.pow(1 + monthlyRate, i);
}
const timeValueBenefit = originalAmount - presentValue;
return {
presentValueOfPayments: presentValue.toFixed(2),
timeValueBenefit: timeValueBenefit.toFixed(2),
effectiveDiscount: ((timeValueBenefit / originalAmount) * 100).toFixed(2),
};
}
// Fallback analysis when AI is unavailable
function getFallbackAnalysis(
body: string,
): typeof responseAnalysisSchema._type {
const lowerBody = body.toLowerCase();
// Simple keyword-based analysis
let intent:
| "acceptance"
| "rejection"
| "counter_offer"
| "request_info"
| "unclear" = "unclear";
let sentiment: "positive" | "negative" | "neutral" = "neutral";
if (
lowerBody.includes("accept") || lowerBody.includes("agree") ||
lowerBody.includes("approved")
) {
intent = "acceptance";
sentiment = "positive";
} else if (
lowerBody.includes("reject") || lowerBody.includes("decline") ||
lowerBody.includes("denied")
) {
intent = "rejection";
sentiment = "negative";
} else if (
lowerBody.includes("counter") || lowerBody.includes("instead") ||
lowerBody.includes("however")
) {
intent = "counter_offer";
sentiment = "neutral";
} else if (
lowerBody.includes("information") || lowerBody.includes("clarify") ||
lowerBody.includes("details")
) {
intent = "request_info";
sentiment = "neutral";
}
// Enhanced extraction using multiple regex patterns
const extractFinancialTerms = (text: string) => {
// Extract dollar amounts
const amountMatches = text.match(/\$(\d+(?:,\d{3})*(?:\.\d{2})?)/g);
const amounts =
amountMatches?.map((match) => parseFloat(match.replace(/[$,]/g, ""))) ||
[];
// Extract monthly payment patterns
const monthlyMatch = text.match(
/\$?(\d+(?:,\d{3})*(?:\.\d{2})?)\s*(?:per month|\/month|monthly)/i,
);
const monthlyAmount = monthlyMatch
? parseFloat(monthlyMatch[1].replace(/,/g, ""))
: undefined;
// Extract number of payments/months
const paymentsMatch = text.match(
/(\d+)\s*(?:months?|payments?|installments?)/i,
);
const numberOfPayments = paymentsMatch
? parseInt(paymentsMatch[1])
: undefined;
// Extract total amount patterns
const totalMatch = text.match(
/(?:total|totaling|total amount)\s*(?:of\s*)?\$?(\d+(?:,\d{3})*(?:\.\d{2})?)/i,
);
const totalAmount = totalMatch
? parseFloat(totalMatch[1].replace(/,/g, ""))
: undefined;
// Determine payment frequency
let paymentFrequency = undefined;
if (text.match(/monthly|per month|\/month/i)) paymentFrequency = "monthly";
else if (text.match(/weekly|per week|\/week/i)) paymentFrequency = "weekly";
else if (text.match(/bi-weekly|biweekly|every two weeks/i)) {
paymentFrequency = "bi-weekly";
}
return {
proposedAmount: amounts.length > 0 ? amounts[0] : undefined,
monthlyAmount,
numberOfPayments,
totalAmount,
paymentFrequency,
allAmounts: amounts,
};
};
const terms = extractFinancialTerms(body);
console.log(
"Fallback Analysis - Extracted Terms:",
JSON.stringify(terms, null, 2),
);
const result = {
intent,
sentiment,
confidence: 0.6, // Lower confidence for fallback
extractedTerms: {
proposedAmount: terms.proposedAmount,
proposedPaymentPlan: terms.monthlyAmount ? "payment plan" : undefined,
paymentTerms: (terms.monthlyAmount || terms.numberOfPayments)
? {
monthlyAmount: terms.monthlyAmount,
numberOfPayments: terms.numberOfPayments,
totalAmount: terms.totalAmount,
paymentFrequency: terms.paymentFrequency,
}
: undefined,
deadline: undefined,
conditions: [],
},
reasoning:
`Generated using enhanced keyword-based fallback analysis. Found ${terms.allAmounts.length} amounts.`,
suggestedNextAction: intent === "acceptance"
? "mark_settled"
: intent === "rejection"
? "escalate_to_user"
: intent === "counter_offer"
? "send_counter"
: "escalate_to_user",
requiresUserReview: true, // Always require review for fallback
};
console.log("Fallback Analysis Result:", JSON.stringify(result, null, 2));
return result;
}
serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: req.headers.get("Authorization")! },
},
},
);
const { debtId, fromEmail, subject, body, messageId }: EmailResponseData =
await req.json();
if (!debtId || !fromEmail || !body) {
return new Response(
JSON.stringify({
error: "Missing required fields: debtId, fromEmail, body",
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Get the debt record and original negotiation context
const { data: debt, error: debtError } = await supabaseClient
.from("debts")
.select("*")
.eq("id", debtId)
.single();
if (debtError || !debt) {
return new Response(
JSON.stringify({ error: "Debt record not found" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Analyze the response using AI
const analysis = await analyzeEmailResponse(
supabaseClient,
debtId,
fromEmail,
subject,
body,
debt.metadata?.aiEmail,
);
console.log({ analysis });
// Store the conversation message
const { error: messageError } = await supabaseClient
.from("conversation_messages")
.update({
debt_id: debtId,
message_type: analysis.intent === "acceptance"
? "acceptance"
: analysis.intent === "rejection"
? "rejection"
: analysis.intent === "counter_offer"
? "counter_offer"
: "response_received",
direction: "inbound",
subject,
body,
from_email: fromEmail,
to_email: debt.metadata?.toEmail || debt.metadata?.fromEmail,
ai_analysis: analysis,
})
.eq("message_id", messageId);
if (messageError) {
console.error("Error storing conversation message:", messageError);
}
// Determine next status and actions based on analysis
let newStatus = debt.status;
let newNegotiationRound = debt.negotiation_round || 1;
let financialOutcome = null;
let shouldAutoRespond = false;
let nextAction = null;
switch (analysis.intent) {
case "acceptance":
newStatus = "accepted";
nextAction = "mark_settled";
// Calculate financial outcome when offer is accepted
financialOutcome = calculateFinancialOutcome(debt, analysis);
break;
case "rejection":
newStatus = "rejected";
nextAction = "escalate_to_user";
break;
case "counter_offer":
newStatus = "counter_negotiating";
newNegotiationRound += 1;
// More conservative auto-response logic for counter-offers
shouldAutoRespond = !analysis.requiresUserReview &&
analysis.confidence > 0.9 && // Increased confidence threshold
analysis.extractedTerms && // Must have specific terms
(analysis.extractedTerms.proposedAmount ||
analysis.extractedTerms.paymentTerms) && // Must have concrete financial terms
body.length > 20; // Must be more than a vague message
nextAction = analysis.suggestedNextAction;
break;
case "request_info":
newStatus = "requires_manual_review";
nextAction = "escalate_to_user";
break;
default:
newStatus = "requires_manual_review";
nextAction = "escalate_to_user";
}
// Update debt record
const updateData: any = {
status: newStatus,
negotiation_round: newNegotiationRound,
conversation_count: (debt.conversation_count || 0) + 1,
last_message_at: new Date().toISOString(),
metadata: {
...debt.metadata,
lastResponse: {
analysis,
receivedAt: new Date().toISOString(),
fromEmail,
subject,
},
},
};
// Add financial outcome if offer was accepted
if (analysis.intent === "acceptance" && financialOutcome) {
updateData.actual_savings = financialOutcome.actualSavings;
updateData.status = "settled"; // Set final status here instead of separate update
updateData.metadata.financialOutcome = {
...financialOutcome,
calculatedAt: new Date().toISOString(),
};
// Keep backward compatibility with actualSavings field
updateData.metadata.actualSavings = {
amount: financialOutcome.actualSavings,
calculatedAt: new Date().toISOString(),
originalAmount: financialOutcome.originalAmount,
acceptedAmount: financialOutcome.acceptedAmount,
savingsPercentage: financialOutcome.originalAmount > 0
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
100).toFixed(2)
: 0,
};
}
const { error: updateError } = await supabaseClient
.from("debts")
.update(updateData)
.eq("id", debtId);
if (updateError) {
console.error("Error updating debt:", updateError);
}
// Log the action
const auditDetails: any = {
intent: analysis.intent,
sentiment: analysis.sentiment,
confidence: analysis.confidence,
fromEmail,
subject,
suggestedAction: analysis.suggestedNextAction,
requiresReview: analysis.requiresUserReview,
};
// Add financial outcome to audit log if offer was accepted
if (analysis.intent === "acceptance" && financialOutcome) {
auditDetails.financialOutcome = financialOutcome;
auditDetails.actualSavings = financialOutcome.actualSavings;
auditDetails.originalAmount = financialOutcome.originalAmount;
auditDetails.acceptedAmount = financialOutcome.acceptedAmount;
auditDetails.savingsPercentage = financialOutcome.originalAmount > 0
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
100).toFixed(2)
: 0;
}
await supabaseClient
.from("audit_logs")
.insert({
debt_id: debtId,
action: analysis.intent === "acceptance"
? "offer_accepted"
: "response_analyzed",
details: {
...auditDetails,
nextAction,
shouldAutoRespond,
negotiationRound: newNegotiationRound,
reasoning: analysis.reasoning,
},
});
// If this is an acceptance, mark as settled
if (analysis.intent === "acceptance") {
// await supabaseClient
// .from("debts")
// .update({ status: "settled" })
// .eq("id", debtId);
await supabaseClient.from("audit_logs").insert({
debt_id: debtId,
action: "debt_settled",
details: {
finalAmount: financialOutcome?.acceptedAmount || debt.amount,
actualSavings: financialOutcome?.actualSavings || 0,
settlementTerms: analysis.extractedTerms,
},
});
}
console.log({
shouldAutoRespond,
analysisIntent: analysis.intent,
analysisConfidence: analysis.confidence,
});
// TEMPORARILY DISABLED: Auto-responses need more testing
// TODO: Re-enable after thorough testing and user preference settings
const AUTO_RESPONSES_ENABLED = true;
const conversationHistory = await getConversationHistory(
supabaseClient,
debtId,
);
console.log({
conversationHistoryLength: conversationHistory.length,
});
// If auto-response is recommended and confidence is high, trigger negotiation
if (
AUTO_RESPONSES_ENABLED &&
shouldAutoRespond && analysis.confidence > 0.8 &&
analysis.intent === "counter_offer" &&
// the length of the conversation isn't bigger than 2 messages
conversationHistory.length <= 2
) {
try {
const negotiateUrl = `${
Deno.env.get("SUPABASE_URL")
}/functions/v1/negotiate`;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (negotiateUrl && serviceKey) {
await fetch(negotiateUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${serviceKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
record: {
...debt,
status: newStatus,
conversation_count: (debt.conversation_count || 0) + 1,
negotiation_round: newNegotiationRound,
},
counterOfferContext: {
previousResponse: body,
extractedTerms: analysis.extractedTerms,
sentiment: analysis.sentiment,
},
}),
});
await supabaseClient.from("audit_logs").insert({
debt_id: debtId,
action: "auto_counter_triggered",
details: {
confidence: analysis.confidence,
extractedTerms: analysis.extractedTerms,
},
});
}
} catch (autoResponseError) {
console.error("Error triggering auto-response:", autoResponseError);
}
}
const responseData: any = {
success: true,
analysis,
newStatus,
negotiationRound: newNegotiationRound,
};
// Include financial outcome in response if offer was accepted
if (analysis.intent === "acceptance" && financialOutcome) {
responseData.financialOutcome = financialOutcome;
responseData.actualSavings = financialOutcome.actualSavings;
responseData.savingsCalculated = true;
responseData.originalAmount = financialOutcome.originalAmount;
responseData.acceptedAmount = financialOutcome.acceptedAmount;
responseData.savingsPercentage = financialOutcome.originalAmount > 0
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
100).toFixed(2)
: 0;
}
return new Response(
JSON.stringify(responseData),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Error in analyze-response function:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
});

View File

@@ -1,854 +1,25 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { generateObject } from "https://esm.sh/ai@3.4.7";
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
import { z } from "https://esm.sh/zod@3.22.4";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
// Schema for AI response analysis
const responseAnalysisSchema = z.object({
intent: z.enum([
"acceptance",
"rejection",
"counter_offer",
"request_info",
"unclear",
])
.describe("The primary intent of the response"),
sentiment: z.enum(["positive", "negative", "neutral"])
.describe("Overall sentiment of the response"),
confidence: z.number().min(0).max(1)
.describe("Confidence in the intent classification"),
extractedTerms: z.object({
proposedAmount: z.number().optional().describe(
"Any amount mentioned in response",
),
proposedPaymentPlan: z.string().optional().describe(
"Payment plan details if mentioned",
),
paymentTerms: z.object({
monthlyAmount: z.number().optional().describe("Monthly payment amount"),
numberOfPayments: z.number().optional().describe(
"Number of payments/installments",
),
totalAmount: z.number().optional().describe("Total amount to be paid"),
interestRate: z.number().optional().describe(
"Interest rate if applicable",
),
paymentFrequency: z.string().optional().describe(
"Payment frequency (monthly, weekly, etc.)",
),
}).optional().describe("Structured payment plan terms"),
deadline: z.string().optional().describe("Any deadline mentioned"),
conditions: z.array(z.string()).optional().describe(
"Any conditions or requirements mentioned",
),
}).describe("Key terms extracted from the response"),
reasoning: z.string().describe("Explanation of the analysis"),
suggestedNextAction: z.enum([
"accept_offer",
"send_counter",
"request_clarification",
"escalate_to_user",
"mark_settled",
]).describe("Recommended next action"),
requiresUserReview: z.boolean().describe(
"Whether this response needs human review",
),
});
interface EmailResponseData {
debtId: string;
fromEmail: string;
subject: string;
body: string;
messageId?: string;
}
// Retrieve full conversation history for context
async function getConversationHistory(
supabaseClient: any,
debtId: string,
) {
try {
const { data: messages, error } = await supabaseClient
.from("conversation_messages")
.select("*")
.eq("debt_id", debtId)
.order("created_at", { ascending: true });
if (error) {
console.error("Error fetching conversation history:", error);
return [];
}
return messages || [];
} catch (error) {
console.error("Error in getConversationHistory:", error);
return [];
}
}
// AI-powered response analysis
async function analyzeEmailResponse(
supabaseClient: any,
debtId: string,
fromEmail: string,
subject: string,
body: string,
originalNegotiation?: any,
) {
try {
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
if (!googleApiKey) {
console.warn("Google API key not configured, using fallback analysis");
return getFallbackAnalysis(body);
}
console.log("Analyzing email with AI:", {
fromEmail,
subject,
bodyLength: body.length,
});
// Get full conversation history for better context
const conversationHistory = await getConversationHistory(
supabaseClient,
debtId,
);
console.log({
debtId,
fromEmail,
subject,
body,
originalNegotiation,
conversationHistoryLength: conversationHistory.length,
});
const system =
`You are an expert financial analysis AI. Your sole function is to meticulously analyze creditor emails and populate a structured JSON object that conforms to the provided schema.
Your entire output MUST be a single, valid JSON object. Do not include any markdown, explanations, or conversational text outside of the JSON structure itself.
--- FIELD-BY-FIELD INSTRUCTIONS ---
1. **intent**: Classify the creditor's primary intent.
- "acceptance": Clear agreement to our original proposal.
- "rejection": Clear refusal of our proposal without offering new terms.
- "counter_offer": Proposes ANY new financial terms (different amount, payment plan, etc.). This is the most common intent besides acceptance.
- "request_info": Asks for more information (e.g., "Can you provide proof of hardship?").
- "unclear": The purpose of the email cannot be determined.
2. **sentiment**: Classify the emotional tone of the email.
- "positive": Cooperative, polite, agreeable language.
- "negative": Hostile, demanding, or threatening language.
- "neutral": Strictly professional, factual, and devoid of emotional language.
3. **confidence**: Provide a score from 0.0 to 1.0 for your "intent" classification. (e.g., 0.95).
4. **extractedTerms**:
- **proposedAmount**: Extract a single, lump-sum settlement amount if offered. If the offer is a payment plan, this field should be null.
- **proposedPaymentPlan**: Capture the payment plan offer as a descriptive string, exactly as the creditor states it. e.g., "$100 a month for 12 months". If no plan is mentioned, this is null.
- **paymentTerms**: If a payment plan is mentioned, break it down into its structured components here. If no plan is mentioned, this entire object should be null.
- **monthlyAmount**: The specific amount for each payment.
- **numberOfPayments**: The number of installments.
- **totalAmount**: The total payout of the plan, ONLY if explicitly stated (e.g., "...totaling $1200").
- **interestRate**: The interest rate as a number (e.g., for "5% interest", extract 5).
- **paymentFrequency**: The frequency (e.g., "monthly", "weekly", "bi-weekly").
- **deadline**: Extract any specific date or timeframe for action (e.g., "by June 30th", "within 10 days").
- **conditions**: Extract all non-financial requirements as an array of strings. Example: ["Payment must be via certified funds", "A settlement agreement must be signed"]. If none, use an empty array [].
5. **reasoning**: Briefly explain the logic behind your "intent" and "suggestedNextAction" classifications, referencing parts of the email.
6. **suggestedNextAction**: Recommend the most logical business action.
- "accept_offer": The creditor's offer matches or is better than our goal.
- "send_counter": The creditor made a counter-offer that we should negotiate.
- "request_clarification": The email is ambiguous or missing key information.
- "escalate_to_user": The response is hostile, contains legal threats, is complex, or requires a human decision.
- "mark_settled": The email confirms the debt is fully settled and no further action is needed.
7. **requiresUserReview**: Set to 'true' if intent is "unclear", sentiment is "negative", confidence is below 0.85, the email contains unusual legal language, the "suggestedNextAction" is "escalate_to_user", OR if the message is vague/lacks specific financial terms (e.g., "think of a better offer", "we need more", etc.). Only set to 'false' for clear, specific counter-offers with concrete terms.`;
// Build conversation history context
// const conversationContext = conversationHistory.length > 0
// ? `--- FULL CONVERSATION HISTORY ---
// ${
// conversationHistory.map((msg, index) =>
// `${
// index + 1
// }. ${msg.direction.toUpperCase()} - ${msg.message_type} (${
// new Date(msg.created_at).toLocaleDateString()
// })
// Subject: ${msg.subject || "N/A"}
// Body: ${msg.body.substring(0, 500)}${msg.body.length > 500 ? "..." : ""}
// ${msg.ai_analysis ? `AI Analysis: ${JSON.stringify(msg.ai_analysis)}` : ""}
// ---`
// ).join("\n")
// }
// `
// : "";
const conversationContext = conversationHistory.length > 0
? `--- FULL CONVERSATION HISTORY ---
${
conversationHistory.map((msg, index) =>
`${
index + 1
}. ${msg.direction.toUpperCase()} - ${msg.message_type} (${
new Date(msg.created_at).toLocaleDateString()
})
Subject: ${msg.subject || "N/A"}
Body: ${msg.body}
${msg.ai_analysis ? `AI Analysis: ${JSON.stringify(msg.ai_analysis)}` : ""}
---`
).join("\n")
}
`
: "";
const prompt =
`Analyze the following email and extract the financial details and intent, populating the JSON object according to your system instructions.
--- CURRENT EMAIL TO ANALYZE ---
From: ${fromEmail}
Subject: ${subject}
Body: ${body}
${conversationContext}
${
originalNegotiation
? `--- MOST RECENT NEGOTIATION CONTEXT ---
Our Negotiation Strategy: ${originalNegotiation.strategy}
Our Proposed Amount: $${originalNegotiation.proposedAmount || "N/A"}
Our Proposed Terms: ${originalNegotiation.terms || "N/A"}
Our Reasoning: ${originalNegotiation.reasoning || "N/A"}
Our Latest Email Body: ${originalNegotiation.body || "N/A"}
`
: ""
}`;
console.log("AI Analysis System:", system);
console.log("AI Analysis Prompt:", prompt);
const result = await generateObject({
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system,
prompt,
schema: responseAnalysisSchema,
});
console.log("AI Analysis Result:", JSON.stringify(result.object, null, 2));
console.log(
"Extracted Terms:",
JSON.stringify(result.object.extractedTerms, null, 2),
);
return result.object;
} catch (error) {
console.error("AI response analysis error:", error);
console.log("Falling back to regex-based analysis");
return getFallbackAnalysis(body);
}
}
// Calculate financial outcome when offer is accepted
function calculateFinancialOutcome(debt: any, analysis: any): any {
try {
const originalAmount = debt.amount || 0;
let acceptedAmount = originalAmount;
let paymentStructure = null;
let financialBenefit = null;
// Try to extract accepted amount from AI analysis
if (analysis.extractedTerms?.proposedAmount) {
acceptedAmount = analysis.extractedTerms.proposedAmount;
} else if (analysis.extractedTerms?.paymentTerms?.totalAmount) {
acceptedAmount = analysis.extractedTerms.paymentTerms.totalAmount;
} else if (debt.metadata?.prospectedSavings?.amount) {
// Fall back to original negotiation terms if no specific amount mentioned
acceptedAmount = originalAmount - debt.metadata.prospectedSavings.amount;
}
// Analyze payment structure if present
if (analysis.extractedTerms?.paymentTerms) {
const terms = analysis.extractedTerms.paymentTerms;
paymentStructure = {
type: "installment_plan",
monthlyAmount: terms.monthlyAmount,
numberOfPayments: terms.numberOfPayments,
totalAmount: terms.totalAmount || acceptedAmount,
frequency: terms.paymentFrequency || "monthly",
interestRate: terms.interestRate || 0,
};
// Calculate time value and cash flow benefits
if (terms.monthlyAmount && terms.numberOfPayments) {
const totalPayments = terms.monthlyAmount * terms.numberOfPayments;
const timeToComplete = terms.numberOfPayments; // in months
financialBenefit = {
type: "payment_restructuring",
principalReduction: Math.max(0, originalAmount - totalPayments),
cashFlowRelief: {
monthlyReduction: originalAmount - terms.monthlyAmount,
extendedTermMonths: timeToComplete,
totalCashFlowBenefit: (originalAmount - terms.monthlyAmount) *
timeToComplete,
},
timeValueBenefit: calculateTimeValueBenefit(
originalAmount,
terms.monthlyAmount,
timeToComplete,
),
};
}
}
// Calculate actual savings (principal reduction)
const actualSavings = Math.max(0, originalAmount - acceptedAmount);
// Determine the primary financial benefit
if (actualSavings > 0) {
financialBenefit = {
type: "principal_reduction",
amount: actualSavings,
percentage: (actualSavings / originalAmount * 100).toFixed(2),
description: `${
((actualSavings / originalAmount) * 100).toFixed(1)
}% principal reduction`,
};
} else if (paymentStructure && paymentStructure.monthlyAmount) {
// No principal reduction but payment restructuring
const monthlyReduction = originalAmount - paymentStructure.monthlyAmount;
financialBenefit = {
type: "payment_restructuring",
monthlyReduction: monthlyReduction,
extendedTermMonths: paymentStructure.numberOfPayments,
description:
`Payment restructured to $${paymentStructure.monthlyAmount}/month over ${paymentStructure.numberOfPayments} months`,
cashFlowBenefit: monthlyReduction > 0
? `$${monthlyReduction}/month cash flow relief`
: "Extended payment terms",
};
}
console.log(
`Financial outcome: Original: $${originalAmount}, Accepted: $${acceptedAmount}, Savings: $${actualSavings}`,
);
return {
actualSavings,
acceptedAmount,
paymentStructure,
financialBenefit,
originalAmount,
};
} catch (error) {
console.error("Error calculating financial outcome:", error);
// Return basic fallback
return {
actualSavings: debt.projected_savings || 0,
acceptedAmount: debt.amount,
paymentStructure: null,
financialBenefit: null,
originalAmount: debt.amount,
};
}
}
// Calculate time value benefit of extended payment terms
function calculateTimeValueBenefit(
originalAmount: number,
monthlyPayment: number,
months: number,
): any {
// Simple present value calculation assuming 5% annual discount rate
const monthlyRate = 0.05 / 12;
let presentValue = 0;
for (let i = 1; i <= months; i++) {
presentValue += monthlyPayment / Math.pow(1 + monthlyRate, i);
}
const timeValueBenefit = originalAmount - presentValue;
return {
presentValueOfPayments: presentValue.toFixed(2),
timeValueBenefit: timeValueBenefit.toFixed(2),
effectiveDiscount: ((timeValueBenefit / originalAmount) * 100).toFixed(2),
};
}
// Fallback analysis when AI is unavailable
function getFallbackAnalysis(
body: string,
): typeof responseAnalysisSchema._type {
const lowerBody = body.toLowerCase();
// Simple keyword-based analysis
let intent:
| "acceptance"
| "rejection"
| "counter_offer"
| "request_info"
| "unclear" = "unclear";
let sentiment: "positive" | "negative" | "neutral" = "neutral";
if (
lowerBody.includes("accept") || lowerBody.includes("agree") ||
lowerBody.includes("approved")
) {
intent = "acceptance";
sentiment = "positive";
} else if (
lowerBody.includes("reject") || lowerBody.includes("decline") ||
lowerBody.includes("denied")
) {
intent = "rejection";
sentiment = "negative";
} else if (
lowerBody.includes("counter") || lowerBody.includes("instead") ||
lowerBody.includes("however")
) {
intent = "counter_offer";
sentiment = "neutral";
} else if (
lowerBody.includes("information") || lowerBody.includes("clarify") ||
lowerBody.includes("details")
) {
intent = "request_info";
sentiment = "neutral";
}
// Enhanced extraction using multiple regex patterns
const extractFinancialTerms = (text: string) => {
// Extract dollar amounts
const amountMatches = text.match(/\$(\d+(?:,\d{3})*(?:\.\d{2})?)/g);
const amounts =
amountMatches?.map((match) => parseFloat(match.replace(/[$,]/g, ""))) ||
[];
// Extract monthly payment patterns
const monthlyMatch = text.match(
/\$?(\d+(?:,\d{3})*(?:\.\d{2})?)\s*(?:per month|\/month|monthly)/i,
);
const monthlyAmount = monthlyMatch
? parseFloat(monthlyMatch[1].replace(/,/g, ""))
: undefined;
// Extract number of payments/months
const paymentsMatch = text.match(
/(\d+)\s*(?:months?|payments?|installments?)/i,
);
const numberOfPayments = paymentsMatch
? parseInt(paymentsMatch[1])
: undefined;
// Extract total amount patterns
const totalMatch = text.match(
/(?:total|totaling|total amount)\s*(?:of\s*)?\$?(\d+(?:,\d{3})*(?:\.\d{2})?)/i,
);
const totalAmount = totalMatch
? parseFloat(totalMatch[1].replace(/,/g, ""))
: undefined;
// Determine payment frequency
let paymentFrequency = undefined;
if (text.match(/monthly|per month|\/month/i)) paymentFrequency = "monthly";
else if (text.match(/weekly|per week|\/week/i)) paymentFrequency = "weekly";
else if (text.match(/bi-weekly|biweekly|every two weeks/i)) {
paymentFrequency = "bi-weekly";
}
return {
proposedAmount: amounts.length > 0 ? amounts[0] : undefined,
monthlyAmount,
numberOfPayments,
totalAmount,
paymentFrequency,
allAmounts: amounts,
};
};
const terms = extractFinancialTerms(body);
console.log(
"Fallback Analysis - Extracted Terms:",
JSON.stringify(terms, null, 2),
);
const result = {
intent,
sentiment,
confidence: 0.6, // Lower confidence for fallback
extractedTerms: {
proposedAmount: terms.proposedAmount,
proposedPaymentPlan: terms.monthlyAmount ? "payment plan" : undefined,
paymentTerms: (terms.monthlyAmount || terms.numberOfPayments)
? {
monthlyAmount: terms.monthlyAmount,
numberOfPayments: terms.numberOfPayments,
totalAmount: terms.totalAmount,
paymentFrequency: terms.paymentFrequency,
}
: undefined,
deadline: undefined,
conditions: [],
},
reasoning:
`Generated using enhanced keyword-based fallback analysis. Found ${terms.allAmounts.length} amounts.`,
suggestedNextAction: intent === "acceptance"
? "mark_settled"
: intent === "rejection"
? "escalate_to_user"
: intent === "counter_offer"
? "send_counter"
: "escalate_to_user",
requiresUserReview: true, // Always require review for fallback
};
console.log("Fallback Analysis Result:", JSON.stringify(result, null, 2));
return result;
}
serve(async (req) => {
Deno.serve(async (req) => {
// Handle CORS preflight requests
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: req.headers.get("Authorization")! },
},
},
);
const { debtId, fromEmail, subject, body, messageId }: EmailResponseData =
await req.json();
if (!debtId || !fromEmail || !body) {
// 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" },
},
);
}
});

View File

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

View File

@@ -8,8 +8,6 @@
- Updates metadata with approval timestamp
*/
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
@@ -17,219 +15,21 @@ const corsHeaders = {
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
interface ApproveDebtRequest {
debtId: string;
approvalNote?: string;
}
interface DebtRecord {
id: string;
vendor: string;
amount: number;
raw_email: string;
negotiated_plan?: string;
status: string;
metadata?: {
aiEmail?: {
subject: string;
body: string;
strategy: string;
};
fromEmail?: string;
};
}
Deno.serve(async (req) => {
// Handle CORS preflight requests
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
if (req.method !== "POST") {
return new Response(
JSON.stringify({ error: "Method not allowed" }),
{
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// 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" },
},
);
}
});

View File

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

View File

@@ -8,11 +8,6 @@
- Ensures FDCPA compliance
*/
import { createClient } from "npm:@supabase/supabase-js@2";
import { generateObject } from "npm:ai@4.3.16";
import { createGoogleGenerativeAI } from "npm:@ai-sdk/google@1.2.19";
import { z } from "npm:zod@3.23.8";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
@@ -20,605 +15,21 @@ const corsHeaders = {
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
// Schema for AI-generated negotiation email
const negotiationEmailSchema = z.object({
subject: z.string().describe("The email subject line"),
body: z.string().describe(
"The complete email body text with proper formatting and placeholders for missing data",
),
strategy: z.enum(["extension", "installment", "settlement", "dispute"])
.describe("The recommended negotiation strategy"),
confidenceLevel: z.number().min(0).max(1).describe(
"Confidence in the strategy recommendation",
),
projectedSavings: z.number().min(0).describe(
"Estimated savings from this strategy",
),
reasoning: z.string().describe("Explanation of why this strategy was chosen"),
customTerms: z.object({
extensionDays: z.number().optional().describe(
"Days for extension if applicable",
),
installmentMonths: z.number().optional().describe(
"Number of months for installment plan",
),
settlementPercentage: z.number().optional().describe(
"Settlement percentage (0-1) if applicable",
),
monthlyPayment: z.number().optional().describe(
"Monthly payment amount for installments",
),
}).describe("Custom terms based on the strategy"),
});
interface PersonalData {
full_name?: string;
address_line_1?: string;
address_line_2?: string;
city?: string;
state?: string;
zip_code?: string;
phone_number?: string;
}
interface DebtRecord {
id: string;
vendor: string;
amount: number;
raw_email: string;
description?: string;
due_date?: string;
user_id?: string;
metadata?: {
isDebtCollection?: boolean;
subject?: string;
fromEmail?: string;
toEmail?: string;
aiEmail?: {
subject: string;
body: string;
strategy: string;
confidence: number;
reasoning: string;
customTerms: Record<string, unknown>;
};
lastResponse?: {
analysis: Record<string, unknown>;
receivedAt: string;
fromEmail: string;
subject: string;
};
};
}
interface CounterOfferContext {
previousResponse: string;
extractedTerms: {
proposedAmount?: number;
proposedPaymentPlan?: string;
monthlyAmount?: number;
numberOfPayments?: number;
totalAmount?: number;
paymentFrequency?: string;
};
sentiment: string;
}
// AI-powered negotiation email generator
async function generateNegotiationEmail(
record: DebtRecord,
personalData: PersonalData,
counterOfferContext?: CounterOfferContext,
) {
try {
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
if (!googleApiKey) {
console.warn("Google API key not configured, falling back to template");
return generateFallbackEmail(record, personalData);
}
// Build context-aware system prompt
let systemPrompt =
`You are an expert debt negotiation advisor specializing in FDCPA-compliant email generation.
Create professional, formal negotiation emails that:
- Include appropriate subject line and email body
- Follow Fair Debt Collection Practices Act requirements
- Use the provided personal data in proper letter format
- Include specific negotiation terms based on debt amount
- Use {{ variable }} syntax for missing or uncertain data (like account numbers, specific dates)
- Maintain professional tone throughout
- Include proper business letter formatting
Strategy guidelines based on amount:
- Extension: For temporary hardship, usually < $500
- Installment: For manageable monthly payments, $500-$2000
- Settlement: For significant savings, typically $2000+
- Dispute: If debt validity is questionable
For missing personal data, use appropriate placeholders.
For uncertain information like account numbers, use {{ Account Number }} format.`;
// Build context-aware prompt
let prompt = `Generate a complete negotiation email for this debt:
Debt Amount: $${record.amount}
Vendor: ${record.vendor}
Description: ${record.description || "Not specified"}
Due Date: ${record.due_date || "Not specified"}
Email Content Preview: ${record.raw_email.substring(0, 500)}...
Personal Data Available:
- Full Name: ${personalData.full_name || "{{ Full Name }}"}
- Address: ${personalData.address_line_1 || "{{ Address Line 1 }}"} ${
personalData.address_line_2 ? personalData.address_line_2 : ""
}
- City: ${personalData.city || "{{ City }}"}
- State: ${personalData.state || "{{ State }}"}
- Zip: ${personalData.zip_code || "{{ Zip Code }}"}
- Phone: ${personalData.phone_number || "{{ Phone Number }}"}`;
// Add counter-offer context if this is a response to a creditor's counter-offer
if (counterOfferContext) {
systemPrompt += `
IMPORTANT: This is a COUNTER-RESPONSE to a creditor's previous response. You must:
- Acknowledge their previous response professionally
- Address their specific terms or concerns
- Make a strategic counter-offer that moves toward resolution
- Show willingness to negotiate while protecting the debtor's interests
- Reference specific amounts or terms they mentioned
- Maintain momentum in the negotiation process`;
prompt += `
CREDITOR'S PREVIOUS RESPONSE CONTEXT:
- Their Response: ${counterOfferContext.previousResponse}
- Sentiment: ${counterOfferContext.sentiment}
- Extracted Terms: ${JSON.stringify(counterOfferContext.extractedTerms)}
Generate a strategic counter-response that acknowledges their position and makes a reasonable counter-offer.`;
} else {
prompt += `
Create a professional initial negotiation email with subject and body.`;
}
console.log({ systemPrompt, prompt });
const result = await generateObject({
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system: systemPrompt,
prompt: prompt,
schema: negotiationEmailSchema,
});
return result.object;
} catch (error) {
console.error("AI email generation error:", error);
return generateFallbackEmail(record, personalData);
}
}
// Fallback email generation when AI is unavailable
function generateFallbackEmail(record: DebtRecord, personalData: PersonalData) {
let strategy: "extension" | "installment" | "settlement" | "dispute" =
"extension";
let projectedSavings = 0;
let customTerms = {};
if (record.amount < 500) {
strategy = "extension";
projectedSavings = 0;
customTerms = { extensionDays: 30 };
} else if (record.amount >= 500 && record.amount < 2000) {
strategy = "installment";
projectedSavings = record.amount * 0.1;
customTerms = {
installmentMonths: 3,
monthlyPayment: Math.round(record.amount / 3 * 100) / 100,
};
} else {
strategy = "settlement";
projectedSavings = record.amount * 0.4;
customTerms = { settlementPercentage: 0.6 };
}
const subject =
`Account Number: {{ Account Number }} - Payment Arrangement Request`;
const body = generateNegotiationLetter(record, strategy, personalData);
return {
subject,
body,
strategy,
confidenceLevel: 0.7,
projectedSavings,
reasoning: "Generated using rule-based fallback logic",
customTerms,
};
}
// Generate negotiation letter for fallback
function generateNegotiationLetter(
record: DebtRecord,
strategy: string,
personalData: PersonalData,
): string {
const senderInfo = `${personalData.full_name || "{{ Full Name }}"}
${personalData.address_line_1 || "{{ Address Line 1 }}"} ${
personalData.address_line_2 ? personalData.address_line_2 : ""
}
${personalData.city || "{{ City }}"}, ${personalData.state || "{{ State }}"} ${
personalData.zip_code || "{{ Zip Code }}"
}
${personalData.phone_number || "{{ Phone Number }}"}
{{ Date }}`;
const vendorDomain = record.vendor.includes("@")
? record.vendor.split("@")[1]
: record.vendor;
const companyName = vendorDomain.split(".")[0].toUpperCase();
const recipientInfo = `${companyName} Collections Department
{{ Collection Agency Address }}`;
const baseResponse = `${senderInfo}
${recipientInfo}
Subject: Account Number: {{ Account Number }}
To Whom It May Concern,
This letter is regarding the debt associated with the account number referenced above, originally with ${record.vendor}, in the amount of $${
record.amount.toFixed(2)
}.
I am writing to propose a payment arrangement to resolve this matter.`;
let proposal = "";
switch (strategy) {
case "extension": {
proposal =
` I respectfully request a 30-day extension to arrange full payment. I anticipate being able to settle this account in full by {{ Proposed Payment Date }}.
During this extension period, I request that no additional fees or interest be applied to maintain the current balance.`;
break;
}
case "installment": {
const monthlyPayment = (record.amount / 3).toFixed(2);
proposal = ` I am able to pay the full balance of $${
record.amount.toFixed(2)
} through an installment plan. I propose to make three (3) equal monthly payments of $${monthlyPayment}, with the first payment to be made on {{ Proposed Start Date }}.`;
break;
}
case "settlement": {
const settlementAmount = (record.amount * 0.6).toFixed(2);
proposal =
` I would like to propose a lump-sum settlement offer of $${settlementAmount} (60% of the current balance) to resolve this matter completely.
This settlement would be paid within 10 business days of written acceptance of this offer. Upon payment, I request written confirmation that this account will be considered paid in full and closed.`;
break;
}
case "dispute": {
proposal =
` I am formally disputing this debt and requesting validation under Section 809(b) of the Fair Debt Collection Practices Act.
Please provide:
- Verification of the debt amount
- Name and address of the original creditor
- Copy of any judgment (if applicable)
- Verification of your authority to collect this debt
Until proper validation is provided, I request that all collection activities cease.`;
break;
}
}
const closingResponse = `
Please confirm in writing your acceptance of this installment plan. Upon receiving your written agreement, I will begin making the proposed payments according to the schedule.
In accordance with the Fair Debt Collection Practices Act (FDCPA), I request validation of this debt. Please provide verification of the debt, including documentation showing the original creditor, the amount owed, and that you are legally authorized to collect this debt. I understand that you must cease collection efforts until this validation is provided.
I look forward to your prompt response and confirmation of this payment arrangement.
Sincerely,
${personalData.full_name || "{{ Your Typed Name }}"}`;
return baseResponse + proposal + closingResponse;
}
Deno.serve(async (req) => {
// Handle CORS preflight requests
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
if (req.method !== "POST") {
return new Response(
JSON.stringify({ error: "Method not allowed" }),
{
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Check if this is a webhook call (using service role) or authenticated user call
const authHeader = req.headers.get("Authorization");
const isServiceRoleCall = authHeader?.includes(
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "",
);
let user: { id: string } | null = null;
let supabaseClient;
if (isServiceRoleCall) {
// This is a webhook/service call - use admin client
supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
);
// For webhook calls, we'll get the userId from the request body along with the record
const { record, counterOfferContext }: {
record: DebtRecord;
counterOfferContext?: CounterOfferContext;
} = await req.json();
if (!record || !record.user_id) {
// 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" } },
);
}

View File

@@ -0,0 +1,494 @@
/*
# Email Sending Edge Function
This function sends negotiated emails via Postmark:
- Validates user has server token configured
- Processes email variables and replaces placeholders
- Sends the approved negotiation email to the debt collector
- Updates debt status and logs the action
- Ensures FDCPA compliance
*/
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
interface SendEmailRequest {
debtId: string;
}
interface UserProfile {
postmark_server_token?: string;
}
interface DebtRecord {
id: string;
vendor: string;
amount: number;
raw_email: string;
negotiated_plan?: string;
projected_savings?: number;
conversation_count?: number;
metadata?: {
aiEmail?: {
subject: string;
body: string;
strategy: string;
confidence?: number;
};
toEmail?: string;
fromEmail?: string;
};
}
// Send email using Postmark
async function sendEmailViaPostmark(
serverToken: string,
fromEmail: string,
toEmail: string,
subject: string,
body: string,
) {
const postmarkEndpoint = "https://api.postmarkapp.com/email";
const emailData = {
From: fromEmail,
To: toEmail,
Subject: subject,
TextBody: body,
MessageStream: "outbound",
};
const response = await fetch(postmarkEndpoint, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"X-Postmark-Server-Token": serverToken,
},
body: JSON.stringify(emailData),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Postmark API error: ${response.status} - ${errorData}`);
}
return await response.json();
}
// Extract variables from text in {{ variable }} format
function extractVariables(text: string): string[] {
const variableRegex = /\{\{([^}]+)\}\}/g;
const variables: string[] = [];
let match;
while ((match = variableRegex.exec(text)) !== null) {
variables.push(match[1].trim());
}
return [...new Set(variables)]; // Remove duplicates
}
// Replace variables in text with their values
function replaceVariables(
text: string,
variables: Record<string, string>,
): string {
return text.replace(/\{\{([^}]+)\}\}/g, (match, variableName) => {
const trimmedName = variableName.trim();
return variables[trimmedName] || ""; // Replace with empty string if variable not found
});
}
// Load variables from database for a specific debt
async function loadVariablesFromDatabase(
supabaseClient: any,
debtId: string,
): Promise<Record<string, string>> {
try {
const { data: dbVariables, error } = await supabaseClient
.from("debt_variables")
.select("variable_name, variable_value")
.eq("debt_id", debtId);
if (error) throw error;
const loadedVariables: Record<string, string> = {};
dbVariables?.forEach((dbVar: any) => {
loadedVariables[dbVar.variable_name] = dbVar.variable_value || "";
});
return loadedVariables;
} catch (error) {
console.error("Error loading variables:", error);
return {};
}
}
// Process email template by replacing variables with their values
async function processEmailTemplate(
supabaseClient: any,
debtId: string,
subject: string,
body: string,
): Promise<
{
processedSubject: string;
processedBody: string;
hasUnfilledVariables: boolean;
}
> {
// Extract all variables from subject and body
const allText = `${subject} ${body}`;
const extractedVars = extractVariables(allText);
// Load saved variables from database
const savedVariables = await loadVariablesFromDatabase(
supabaseClient,
debtId,
);
// Check if all variables have values
const unfilledVariables = extractedVars.filter((variable) =>
!savedVariables[variable] || savedVariables[variable].trim() === ""
);
const hasUnfilledVariables = unfilledVariables.length > 0;
// Replace variables in subject and body
const processedSubject = replaceVariables(subject, savedVariables);
const processedBody = replaceVariables(body, savedVariables);
return {
processedSubject,
processedBody,
hasUnfilledVariables,
};
}
// Extract email address from various formats
function extractEmailAddress(emailString: string): string {
// Handle formats like "Name <email@domain.com>" or just "email@domain.com"
const emailMatch = emailString.match(/<([^>]+)>/) ||
emailString.match(/([^\s<>]+@[^\s<>]+)/);
return emailMatch ? emailMatch[1] : emailString;
}
Deno.serve(async (req) => {
try {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
// Initialize Supabase client with auth context
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: req.headers.get("Authorization") ?? "" },
},
},
);
// Get the authenticated user
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Authorization header required" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const token = authHeader.replace("Bearer ", "");
const { data: userData } = await supabaseClient.auth.getUser(token);
const user = userData.user;
if (!user) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const { debtId }: SendEmailRequest = await req.json();
if (!debtId) {
return new Response(
JSON.stringify({ error: "Missing debtId" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch user profile with server token - using authenticated client
const { data: userProfile, error: userError } = await supabaseClient
.from("user_profiles")
.select("postmark_server_token")
.eq("user_id", user.id)
.single();
if (userError || !userProfile) {
return new Response(
JSON.stringify({ error: "User not found" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const userProfileData = userProfile as UserProfile;
if (!userProfileData.postmark_server_token) {
return new Response(
JSON.stringify({
error: "Postmark server token not configured",
requiresConfiguration: true,
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Fetch debt record - RLS will ensure user can only access their own debts
const { data: debtRecord, error: debtError } = await supabaseClient
.from("debts")
.select("*")
.eq("id", debtId)
.eq("user_id", user.id)
.single();
if (debtError || !debtRecord) {
return new Response(
JSON.stringify({ error: "Debt record not found" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const debt = debtRecord as DebtRecord;
// Validate that negotiated plan exists
if (!debt.negotiated_plan || !debt.metadata?.aiEmail) {
return new Response(
JSON.stringify({
error: "No negotiated plan found. Please generate negotiation first.",
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Extract email details and process variables
const { subject: rawSubject, body: rawBody } = debt.metadata.aiEmail;
const fromEmail = debt.metadata?.toEmail || user.email;
if (!fromEmail) {
return new Response(
JSON.stringify({ error: "No valid sender email found" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Process email template and replace variables
const { processedSubject, processedBody, hasUnfilledVariables } =
await processEmailTemplate(
supabaseClient,
debtId,
rawSubject,
rawBody,
);
// Check if there are unfilled variables
if (hasUnfilledVariables) {
// console.warn("Email contains unfilled variables");
// return new Response(
// JSON.stringify({
// error: "Email contains unfilled variables",
// details:
// "Please fill in all required variables before sending the email.",
// }),
// {
// status: 400,
// headers: { ...corsHeaders, "Content-Type": "application/json" },
// },
// );
}
const subject = processedSubject;
const body = processedBody;
// Determine recipient email
let toEmail = debt.vendor;
if (debt.metadata?.fromEmail) {
toEmail = extractEmailAddress(debt.metadata.fromEmail);
} else if (debt.vendor.includes("@")) {
toEmail = extractEmailAddress(debt.vendor);
} else {
// If vendor doesn't contain email, try to construct one
toEmail = `collections@${
debt.vendor.toLowerCase().replace(/\s+/g, "")
}.com`;
}
try {
// Send email via Postmark
const emailResult = await sendEmailViaPostmark(
userProfileData.postmark_server_token,
fromEmail,
toEmail,
subject,
body,
);
// Update debt status to sent and preserve negotiating state
const { data: currentDebt } = await supabaseClient
.from("debts")
.select("conversation_count, negotiation_round")
.eq("id", debtId)
.single();
const { error: updateError } = await supabaseClient
.from("debts")
.update({
status: "sent",
conversation_count: (currentDebt?.conversation_count || 0) + 1,
last_message_at: new Date().toISOString(),
prospected_savings: debt.projected_savings || 0, // Store prospected savings when sent
metadata: {
...debt.metadata,
emailSent: {
sentAt: new Date().toISOString(),
messageId: emailResult.MessageID,
to: toEmail,
from: fromEmail,
subject: subject,
},
prospectedSavings: {
amount: debt.projected_savings || 0,
percentage: debt.amount > 0
? ((debt.projected_savings || 0) / debt.amount * 100).toFixed(2)
: 0,
calculatedAt: new Date().toISOString(),
strategy: debt.metadata?.aiEmail?.strategy || "unknown",
},
},
})
.eq("id", debtId);
if (updateError) {
console.error("Error updating debt status:", updateError);
}
// Record the sent email in conversation history with processed content
await supabaseClient.from("conversation_messages").insert({
debt_id: debtId,
message_type: "negotiation_sent",
direction: "outbound",
subject: processedSubject, // Use processed subject with variables replaced
body: processedBody, // Use processed body with variables replaced
from_email: fromEmail,
to_email: toEmail,
message_id: emailResult.MessageID,
ai_analysis: {
strategy: debt.metadata.aiEmail.strategy,
confidence: debt.metadata.aiEmail.confidence,
projectedSavings: debt.projected_savings,
},
});
// Log the email sending
await supabaseClient.from("audit_logs").insert({
debt_id: debtId,
action: "email_sent",
details: {
to: toEmail,
subject: subject,
postmarkMessageId: emailResult.MessageID,
conversationRound: currentDebt?.negotiation_round || 1,
strategy: debt.metadata.aiEmail.strategy,
},
});
return new Response(
JSON.stringify({
success: true,
messageId: emailResult.MessageID,
sentTo: toEmail,
sentFrom: fromEmail,
subject: subject,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (emailError) {
console.error("Email sending error:", emailError);
const errorMessage = emailError instanceof Error
? emailError.message
: String(emailError);
// Log the failed attempt - using authenticated client
await supabaseClient
.from("audit_logs")
.insert({
debt_id: debtId,
action: "email_send_failed",
details: {
error: errorMessage,
to: toEmail,
from: fromEmail,
subject: subject,
},
});
return new Response(
JSON.stringify({
error: "Failed to send email",
details: errorMessage,
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
} catch (error) {
console.error("Send email function error:", error);
const errorMessage = error instanceof Error
? error.message
: "An unknown error occurred" +
(Deno.env.get("NODE_ENV") === "development"
? `: ${JSON.stringify(error)}`
: "");
return new Response(
JSON.stringify({ error: errorMessage }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
});

View File

@@ -9,8 +9,6 @@
- Ensures FDCPA compliance
*/
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
@@ -18,477 +16,21 @@ const corsHeaders = {
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
interface SendEmailRequest {
debtId: string;
}
interface UserProfile {
postmark_server_token?: string;
}
interface DebtRecord {
id: string;
vendor: string;
amount: number;
raw_email: string;
negotiated_plan?: string;
projected_savings?: number;
conversation_count?: number;
metadata?: {
aiEmail?: {
subject: string;
body: string;
strategy: string;
confidence?: number;
};
toEmail?: string;
fromEmail?: string;
};
}
// Send email using Postmark
async function sendEmailViaPostmark(
serverToken: string,
fromEmail: string,
toEmail: string,
subject: string,
body: string,
) {
const postmarkEndpoint = "https://api.postmarkapp.com/email";
const emailData = {
From: fromEmail,
To: toEmail,
Subject: subject,
TextBody: body,
MessageStream: "outbound",
};
const response = await fetch(postmarkEndpoint, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"X-Postmark-Server-Token": serverToken,
},
body: JSON.stringify(emailData),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Postmark API error: ${response.status} - ${errorData}`);
}
return await response.json();
}
// Extract variables from text in {{ variable }} format
function extractVariables(text: string): string[] {
const variableRegex = /\{\{([^}]+)\}\}/g;
const variables: string[] = [];
let match;
while ((match = variableRegex.exec(text)) !== null) {
variables.push(match[1].trim());
}
return [...new Set(variables)]; // Remove duplicates
}
// Replace variables in text with their values
function replaceVariables(
text: string,
variables: Record<string, string>,
): string {
return text.replace(/\{\{([^}]+)\}\}/g, (match, variableName) => {
const trimmedName = variableName.trim();
return variables[trimmedName] || ""; // Replace with empty string if variable not found
});
}
// Load variables from database for a specific debt
async function loadVariablesFromDatabase(
supabaseClient: any,
debtId: string,
): Promise<Record<string, string>> {
try {
const { data: dbVariables, error } = await supabaseClient
.from("debt_variables")
.select("variable_name, variable_value")
.eq("debt_id", debtId);
if (error) throw error;
const loadedVariables: Record<string, string> = {};
dbVariables?.forEach((dbVar: any) => {
loadedVariables[dbVar.variable_name] = dbVar.variable_value || "";
});
return loadedVariables;
} catch (error) {
console.error("Error loading variables:", error);
return {};
}
}
// Process email template by replacing variables with their values
async function processEmailTemplate(
supabaseClient: any,
debtId: string,
subject: string,
body: string,
): Promise<
{
processedSubject: string;
processedBody: string;
hasUnfilledVariables: boolean;
}
> {
// Extract all variables from subject and body
const allText = `${subject} ${body}`;
const extractedVars = extractVariables(allText);
// Load saved variables from database
const savedVariables = await loadVariablesFromDatabase(
supabaseClient,
debtId,
);
// Check if all variables have values
const unfilledVariables = extractedVars.filter((variable) =>
!savedVariables[variable] || savedVariables[variable].trim() === ""
);
const hasUnfilledVariables = unfilledVariables.length > 0;
// Replace variables in subject and body
const processedSubject = replaceVariables(subject, savedVariables);
const processedBody = replaceVariables(body, savedVariables);
return {
processedSubject,
processedBody,
hasUnfilledVariables,
};
}
// Extract email address from various formats
function extractEmailAddress(emailString: string): string {
// Handle formats like "Name <email@domain.com>" or just "email@domain.com"
const emailMatch = emailString.match(/<([^>]+)>/) ||
emailString.match(/([^\s<>]+@[^\s<>]+)/);
return emailMatch ? emailMatch[1] : emailString;
}
Deno.serve(async (req) => {
try {
// 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" },
},
);
}
});

View File

@@ -0,0 +1,170 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { generateObject } from "https://esm.sh/ai@3.4.7";
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
import { z } from "https://esm.sh/zod@3.22.4";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
// Same schema as analyze-response
const responseAnalysisSchema = z.object({
intent: z.enum([
"acceptance",
"rejection",
"counter_offer",
"request_info",
"unclear",
]).describe("The primary intent of the response"),
sentiment: z.enum(["positive", "negative", "neutral"])
.describe("Overall sentiment of the response"),
confidence: z.number().min(0).max(1)
.describe("Confidence in the intent classification"),
extractedTerms: z.object({
proposedAmount: z.number().optional().describe(
"Any amount mentioned in response",
),
proposedPaymentPlan: z.string().optional().describe(
"Payment plan details if mentioned",
),
paymentTerms: z.object({
monthlyAmount: z.number().optional().describe("Monthly payment amount"),
numberOfPayments: z.number().optional().describe(
"Number of payments/installments",
),
totalAmount: z.number().optional().describe("Total amount to be paid"),
interestRate: z.number().optional().describe(
"Interest rate if applicable",
),
paymentFrequency: z.string().optional().describe(
"Payment frequency (monthly, weekly, etc.)",
),
}).optional().describe("Structured payment plan terms"),
deadline: z.string().optional().describe("Any deadline mentioned"),
conditions: z.array(z.string()).optional().describe(
"Any conditions or requirements mentioned",
),
}).describe("Key terms extracted from the response"),
reasoning: z.string().describe("Explanation of the analysis"),
suggestedNextAction: z.enum([
"accept_offer",
"send_counter",
"request_clarification",
"escalate_to_user",
"mark_settled",
]).describe("Recommended next action"),
requiresUserReview: z.boolean().describe(
"Whether this response needs human review",
),
});
serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const { testEmail } = await req.json();
if (!testEmail) {
return new Response(
JSON.stringify({ error: "testEmail is required" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
if (!googleApiKey) {
return new Response(
JSON.stringify({ error: "Google API key not configured" }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
console.log("Testing extraction with email:", testEmail);
const result = await generateObject({
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system: `You are an expert financial analyst specializing in debt collection and negotiation responses.
Your job is to carefully analyze creditor responses and extract ALL financial terms mentioned.
CRITICAL: Always extract financial information when present. Look for:
AMOUNTS:
- Any dollar amounts mentioned ($1,000, $500, etc.)
- Settlement offers or counter-offers
- Monthly payment amounts
- Total payment amounts
PAYMENT PLANS:
- Monthly payment amounts (e.g., "$200 per month", "$150/month")
- Number of payments/installments (e.g., "12 months", "24 payments")
- Payment frequency (monthly, weekly, bi-weekly)
- Total amounts for payment plans
- Interest rates if mentioned
EXTRACT EVERYTHING: Even if amounts seem obvious, always include them in extractedTerms.`,
prompt: `Analyze this test email and extract ALL financial terms:
EMAIL: ${testEmail}
EXTRACTION REQUIREMENTS:
1. Find ANY dollar amounts mentioned in the email
2. Look for payment plan details (monthly amounts, number of payments)
3. Identify payment frequency (monthly, weekly, etc.)
4. Extract total amounts if mentioned
5. Note any interest rates or fees
6. Capture all conditions and requirements
EXAMPLES OF WHAT TO EXTRACT:
- "We can accept $250 per month" → monthlyAmount: 250
- "for 18 months" → numberOfPayments: 18
- "totaling $4,500" → totalAmount: 4500
- "settlement of $3,200" → proposedAmount: 3200
- "monthly payments" → paymentFrequency: "monthly"
Be thorough and extract ALL financial information present in the email.`,
schema: responseAnalysisSchema,
});
console.log("AI Analysis Result:", JSON.stringify(result.object, null, 2));
return new Response(
JSON.stringify({
success: true,
analysis: result.object,
extractedTerms: result.object.extractedTerms,
debug: {
emailLength: testEmail.length,
hasGoogleAPI: !!googleApiKey,
timestamp: new Date().toISOString()
}
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error in test-extraction function:", error);
return new Response(
JSON.stringify({
error: "Internal server error",
details: error.message
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
});

View File

@@ -1,170 +1,25 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { generateObject } from "https://esm.sh/ai@3.4.7";
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
import { z } from "https://esm.sh/zod@3.22.4";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
// Same schema as analyze-response
const responseAnalysisSchema = z.object({
intent: z.enum([
"acceptance",
"rejection",
"counter_offer",
"request_info",
"unclear",
]).describe("The primary intent of the response"),
sentiment: z.enum(["positive", "negative", "neutral"])
.describe("Overall sentiment of the response"),
confidence: z.number().min(0).max(1)
.describe("Confidence in the intent classification"),
extractedTerms: z.object({
proposedAmount: z.number().optional().describe(
"Any amount mentioned in response",
),
proposedPaymentPlan: z.string().optional().describe(
"Payment plan details if mentioned",
),
paymentTerms: z.object({
monthlyAmount: z.number().optional().describe("Monthly payment amount"),
numberOfPayments: z.number().optional().describe(
"Number of payments/installments",
),
totalAmount: z.number().optional().describe("Total amount to be paid"),
interestRate: z.number().optional().describe(
"Interest rate if applicable",
),
paymentFrequency: z.string().optional().describe(
"Payment frequency (monthly, weekly, etc.)",
),
}).optional().describe("Structured payment plan terms"),
deadline: z.string().optional().describe("Any deadline mentioned"),
conditions: z.array(z.string()).optional().describe(
"Any conditions or requirements mentioned",
),
}).describe("Key terms extracted from the response"),
reasoning: z.string().describe("Explanation of the analysis"),
suggestedNextAction: z.enum([
"accept_offer",
"send_counter",
"request_clarification",
"escalate_to_user",
"mark_settled",
]).describe("Recommended next action"),
requiresUserReview: z.boolean().describe(
"Whether this response needs human review",
),
});
serve(async (req) => {
Deno.serve(async (req) => {
// Handle CORS preflight requests
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const { testEmail } = await req.json();
if (!testEmail) {
return new Response(
JSON.stringify({ error: "testEmail is required" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
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" },
}
);
}
});