mirror of
https://github.com/FranP-code/inbox-negotiator.git
synced 2025-10-13 00:42:26 +00:00
Complete project disabling - all endpoints now return disabled message while keeping auth functional
Co-authored-by: FranP-code <76450203+FranP-code@users.noreply.github.com>
This commit is contained in:
@@ -12,7 +12,6 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Debt } from "../lib/supabase";
|
import type { Debt } from "../lib/supabase";
|
||||||
import { createClient } from "@supabase/supabase-js";
|
|
||||||
|
|
||||||
interface DebtTimelineProps {
|
interface DebtTimelineProps {
|
||||||
debt: Debt;
|
debt: Debt;
|
||||||
|
|||||||
61
src/components/DisabledProject.tsx
Normal file
61
src/components/DisabledProject.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DisabledProjectProps {
|
||||||
|
showAuth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DisabledProject({ showAuth = false }: DisabledProjectProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-background flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-orange-100 dark:bg-orange-900 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<AlertTriangle className="w-8 h-8 text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-foreground mb-4">
|
||||||
|
Project Disabled
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-300 mb-8">
|
||||||
|
The project has been disabled (it was part of a hackathon). To enable it, please contact me.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{showAuth && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
Authentication is still available:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex items-center justify-center px-6 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/signup"
|
||||||
|
className="inline-flex items-center justify-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
// Type definitions for Appwrite migration
|
// Type definitions for Appwrite migration
|
||||||
// This file no longer contains Supabase client - use appwrite.ts instead
|
// 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 = {
|
export type User = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|||||||
@@ -1,584 +1,15 @@
|
|||||||
import type { APIRoute } from "astro";
|
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 }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
// Project has been disabled
|
||||||
// Create admin client for webhook operations
|
return new Response(
|
||||||
let appwriteAdmin;
|
JSON.stringify({
|
||||||
try {
|
error: "Project Disabled",
|
||||||
appwriteAdmin = createAppwriteAdmin();
|
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
|
||||||
} catch (configError) {
|
}),
|
||||||
console.error("Appwrite admin configuration error:", configError);
|
{
|
||||||
return new Response(
|
status: 503,
|
||||||
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);
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: errorInfo.message,
|
|
||||||
details: errorInfo.originalError,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
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" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
}
|
||||||
}
|
);
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import { Configuration as ConfigComponent } from "../components/Configuration";
|
import { DisabledProject } from "../components/DisabledProject";
|
||||||
import { Navbar } from "../components/Navbar";
|
import { Navbar } from "../components/Navbar";
|
||||||
import { AuthGuard } from "../components/AuthGuard";
|
import { AuthGuard } from "../components/AuthGuard";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
@@ -11,7 +11,7 @@ import { Toaster } from "../components/ui/sonner";
|
|||||||
<Navbar client:load />
|
<Navbar client:load />
|
||||||
|
|
||||||
<AuthGuard requireAuth={true} client:load>
|
<AuthGuard requireAuth={true} client:load>
|
||||||
<ConfigComponent client:load />
|
<DisabledProject client:load />
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
<Toaster client:load />
|
<Toaster client:load />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import { Dashboard } from "../components/Dashboard";
|
import { DisabledProject } from "../components/DisabledProject";
|
||||||
import { AuthGuard } from "../components/AuthGuard";
|
import { AuthGuard } from "../components/AuthGuard";
|
||||||
import { Navbar } from "../components/Navbar";
|
import { Navbar } from "../components/Navbar";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
@@ -11,7 +11,7 @@ import { Toaster } from "../components/ui/sonner";
|
|||||||
<Navbar client:load />
|
<Navbar client:load />
|
||||||
|
|
||||||
<AuthGuard requireAuth={true} client:load>
|
<AuthGuard requireAuth={true} client:load>
|
||||||
<Dashboard client:load />
|
<DisabledProject client:load />
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
<Toaster client:load />
|
<Toaster client:load />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,274 +1,11 @@
|
|||||||
---
|
---
|
||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
import { DisabledProject } from "../components/DisabledProject";
|
||||||
import { Navbar } from "../components/Navbar";
|
import { Navbar } from "../components/Navbar";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="InboxNegotiator - AI-Powered Debt Resolution">
|
<Layout title="InboxNegotiator - AI-Powered Debt Resolution">
|
||||||
<Navbar client:load />
|
<Navbar client:load />
|
||||||
|
<DisabledProject showAuth={true} 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>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
854
supabase/functions/analyze-response/index-original.ts
Normal file
854
supabase/functions/analyze-response/index-original.ts
Normal file
@@ -0,0 +1,854 @@
|
|||||||
|
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
import { generateObject } from "https://esm.sh/ai@3.4.7";
|
||||||
|
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
|
||||||
|
import { z } from "https://esm.sh/zod@3.22.4";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers":
|
||||||
|
"authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Schema for AI response analysis
|
||||||
|
const responseAnalysisSchema = z.object({
|
||||||
|
intent: z.enum([
|
||||||
|
"acceptance",
|
||||||
|
"rejection",
|
||||||
|
"counter_offer",
|
||||||
|
"request_info",
|
||||||
|
"unclear",
|
||||||
|
])
|
||||||
|
.describe("The primary intent of the response"),
|
||||||
|
sentiment: z.enum(["positive", "negative", "neutral"])
|
||||||
|
.describe("Overall sentiment of the response"),
|
||||||
|
confidence: z.number().min(0).max(1)
|
||||||
|
.describe("Confidence in the intent classification"),
|
||||||
|
extractedTerms: z.object({
|
||||||
|
proposedAmount: z.number().optional().describe(
|
||||||
|
"Any amount mentioned in response",
|
||||||
|
),
|
||||||
|
proposedPaymentPlan: z.string().optional().describe(
|
||||||
|
"Payment plan details if mentioned",
|
||||||
|
),
|
||||||
|
paymentTerms: z.object({
|
||||||
|
monthlyAmount: z.number().optional().describe("Monthly payment amount"),
|
||||||
|
numberOfPayments: z.number().optional().describe(
|
||||||
|
"Number of payments/installments",
|
||||||
|
),
|
||||||
|
totalAmount: z.number().optional().describe("Total amount to be paid"),
|
||||||
|
interestRate: z.number().optional().describe(
|
||||||
|
"Interest rate if applicable",
|
||||||
|
),
|
||||||
|
paymentFrequency: z.string().optional().describe(
|
||||||
|
"Payment frequency (monthly, weekly, etc.)",
|
||||||
|
),
|
||||||
|
}).optional().describe("Structured payment plan terms"),
|
||||||
|
deadline: z.string().optional().describe("Any deadline mentioned"),
|
||||||
|
conditions: z.array(z.string()).optional().describe(
|
||||||
|
"Any conditions or requirements mentioned",
|
||||||
|
),
|
||||||
|
}).describe("Key terms extracted from the response"),
|
||||||
|
reasoning: z.string().describe("Explanation of the analysis"),
|
||||||
|
suggestedNextAction: z.enum([
|
||||||
|
"accept_offer",
|
||||||
|
"send_counter",
|
||||||
|
"request_clarification",
|
||||||
|
"escalate_to_user",
|
||||||
|
"mark_settled",
|
||||||
|
]).describe("Recommended next action"),
|
||||||
|
requiresUserReview: z.boolean().describe(
|
||||||
|
"Whether this response needs human review",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface EmailResponseData {
|
||||||
|
debtId: string;
|
||||||
|
fromEmail: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
messageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve full conversation history for context
|
||||||
|
async function getConversationHistory(
|
||||||
|
supabaseClient: any,
|
||||||
|
debtId: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { data: messages, error } = await supabaseClient
|
||||||
|
.from("conversation_messages")
|
||||||
|
.select("*")
|
||||||
|
.eq("debt_id", debtId)
|
||||||
|
.order("created_at", { ascending: true });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error fetching conversation history:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in getConversationHistory:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI-powered response analysis
|
||||||
|
async function analyzeEmailResponse(
|
||||||
|
supabaseClient: any,
|
||||||
|
debtId: string,
|
||||||
|
fromEmail: string,
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
originalNegotiation?: any,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
|
||||||
|
if (!googleApiKey) {
|
||||||
|
console.warn("Google API key not configured, using fallback analysis");
|
||||||
|
return getFallbackAnalysis(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Analyzing email with AI:", {
|
||||||
|
fromEmail,
|
||||||
|
subject,
|
||||||
|
bodyLength: body.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get full conversation history for better context
|
||||||
|
const conversationHistory = await getConversationHistory(
|
||||||
|
supabaseClient,
|
||||||
|
debtId,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
debtId,
|
||||||
|
fromEmail,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
originalNegotiation,
|
||||||
|
conversationHistoryLength: conversationHistory.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const system =
|
||||||
|
`You are an expert financial analysis AI. Your sole function is to meticulously analyze creditor emails and populate a structured JSON object that conforms to the provided schema.
|
||||||
|
|
||||||
|
Your entire output MUST be a single, valid JSON object. Do not include any markdown, explanations, or conversational text outside of the JSON structure itself.
|
||||||
|
|
||||||
|
--- FIELD-BY-FIELD INSTRUCTIONS ---
|
||||||
|
|
||||||
|
1. **intent**: Classify the creditor's primary intent.
|
||||||
|
- "acceptance": Clear agreement to our original proposal.
|
||||||
|
- "rejection": Clear refusal of our proposal without offering new terms.
|
||||||
|
- "counter_offer": Proposes ANY new financial terms (different amount, payment plan, etc.). This is the most common intent besides acceptance.
|
||||||
|
- "request_info": Asks for more information (e.g., "Can you provide proof of hardship?").
|
||||||
|
- "unclear": The purpose of the email cannot be determined.
|
||||||
|
|
||||||
|
2. **sentiment**: Classify the emotional tone of the email.
|
||||||
|
- "positive": Cooperative, polite, agreeable language.
|
||||||
|
- "negative": Hostile, demanding, or threatening language.
|
||||||
|
- "neutral": Strictly professional, factual, and devoid of emotional language.
|
||||||
|
|
||||||
|
3. **confidence**: Provide a score from 0.0 to 1.0 for your "intent" classification. (e.g., 0.95).
|
||||||
|
|
||||||
|
4. **extractedTerms**:
|
||||||
|
- **proposedAmount**: Extract a single, lump-sum settlement amount if offered. If the offer is a payment plan, this field should be null.
|
||||||
|
- **proposedPaymentPlan**: Capture the payment plan offer as a descriptive string, exactly as the creditor states it. e.g., "$100 a month for 12 months". If no plan is mentioned, this is null.
|
||||||
|
- **paymentTerms**: If a payment plan is mentioned, break it down into its structured components here. If no plan is mentioned, this entire object should be null.
|
||||||
|
- **monthlyAmount**: The specific amount for each payment.
|
||||||
|
- **numberOfPayments**: The number of installments.
|
||||||
|
- **totalAmount**: The total payout of the plan, ONLY if explicitly stated (e.g., "...totaling $1200").
|
||||||
|
- **interestRate**: The interest rate as a number (e.g., for "5% interest", extract 5).
|
||||||
|
- **paymentFrequency**: The frequency (e.g., "monthly", "weekly", "bi-weekly").
|
||||||
|
- **deadline**: Extract any specific date or timeframe for action (e.g., "by June 30th", "within 10 days").
|
||||||
|
- **conditions**: Extract all non-financial requirements as an array of strings. Example: ["Payment must be via certified funds", "A settlement agreement must be signed"]. If none, use an empty array [].
|
||||||
|
|
||||||
|
5. **reasoning**: Briefly explain the logic behind your "intent" and "suggestedNextAction" classifications, referencing parts of the email.
|
||||||
|
|
||||||
|
6. **suggestedNextAction**: Recommend the most logical business action.
|
||||||
|
- "accept_offer": The creditor's offer matches or is better than our goal.
|
||||||
|
- "send_counter": The creditor made a counter-offer that we should negotiate.
|
||||||
|
- "request_clarification": The email is ambiguous or missing key information.
|
||||||
|
- "escalate_to_user": The response is hostile, contains legal threats, is complex, or requires a human decision.
|
||||||
|
- "mark_settled": The email confirms the debt is fully settled and no further action is needed.
|
||||||
|
|
||||||
|
7. **requiresUserReview**: Set to 'true' if intent is "unclear", sentiment is "negative", confidence is below 0.85, the email contains unusual legal language, the "suggestedNextAction" is "escalate_to_user", OR if the message is vague/lacks specific financial terms (e.g., "think of a better offer", "we need more", etc.). Only set to 'false' for clear, specific counter-offers with concrete terms.`;
|
||||||
|
|
||||||
|
// Build conversation history context
|
||||||
|
// const conversationContext = conversationHistory.length > 0
|
||||||
|
// ? `--- FULL CONVERSATION HISTORY ---
|
||||||
|
// ${
|
||||||
|
// conversationHistory.map((msg, index) =>
|
||||||
|
// `${
|
||||||
|
// index + 1
|
||||||
|
// }. ${msg.direction.toUpperCase()} - ${msg.message_type} (${
|
||||||
|
// new Date(msg.created_at).toLocaleDateString()
|
||||||
|
// })
|
||||||
|
// Subject: ${msg.subject || "N/A"}
|
||||||
|
// Body: ${msg.body.substring(0, 500)}${msg.body.length > 500 ? "..." : ""}
|
||||||
|
// ${msg.ai_analysis ? `AI Analysis: ${JSON.stringify(msg.ai_analysis)}` : ""}
|
||||||
|
// ---`
|
||||||
|
// ).join("\n")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// `
|
||||||
|
// : "";
|
||||||
|
|
||||||
|
const conversationContext = conversationHistory.length > 0
|
||||||
|
? `--- FULL CONVERSATION HISTORY ---
|
||||||
|
${
|
||||||
|
conversationHistory.map((msg, index) =>
|
||||||
|
`${
|
||||||
|
index + 1
|
||||||
|
}. ${msg.direction.toUpperCase()} - ${msg.message_type} (${
|
||||||
|
new Date(msg.created_at).toLocaleDateString()
|
||||||
|
})
|
||||||
|
Subject: ${msg.subject || "N/A"}
|
||||||
|
Body: ${msg.body}
|
||||||
|
${msg.ai_analysis ? `AI Analysis: ${JSON.stringify(msg.ai_analysis)}` : ""}
|
||||||
|
---`
|
||||||
|
).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const prompt =
|
||||||
|
`Analyze the following email and extract the financial details and intent, populating the JSON object according to your system instructions.
|
||||||
|
|
||||||
|
--- CURRENT EMAIL TO ANALYZE ---
|
||||||
|
From: ${fromEmail}
|
||||||
|
Subject: ${subject}
|
||||||
|
Body: ${body}
|
||||||
|
|
||||||
|
${conversationContext}
|
||||||
|
|
||||||
|
${
|
||||||
|
originalNegotiation
|
||||||
|
? `--- MOST RECENT NEGOTIATION CONTEXT ---
|
||||||
|
Our Negotiation Strategy: ${originalNegotiation.strategy}
|
||||||
|
Our Proposed Amount: $${originalNegotiation.proposedAmount || "N/A"}
|
||||||
|
Our Proposed Terms: ${originalNegotiation.terms || "N/A"}
|
||||||
|
Our Reasoning: ${originalNegotiation.reasoning || "N/A"}
|
||||||
|
Our Latest Email Body: ${originalNegotiation.body || "N/A"}
|
||||||
|
`
|
||||||
|
: ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
console.log("AI Analysis System:", system);
|
||||||
|
console.log("AI Analysis Prompt:", prompt);
|
||||||
|
|
||||||
|
const result = await generateObject({
|
||||||
|
model: createGoogleGenerativeAI({
|
||||||
|
apiKey: googleApiKey,
|
||||||
|
})("gemini-2.5-flash-preview-04-17"),
|
||||||
|
system,
|
||||||
|
prompt,
|
||||||
|
schema: responseAnalysisSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("AI Analysis Result:", JSON.stringify(result.object, null, 2));
|
||||||
|
console.log(
|
||||||
|
"Extracted Terms:",
|
||||||
|
JSON.stringify(result.object.extractedTerms, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.object;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("AI response analysis error:", error);
|
||||||
|
console.log("Falling back to regex-based analysis");
|
||||||
|
return getFallbackAnalysis(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate financial outcome when offer is accepted
|
||||||
|
function calculateFinancialOutcome(debt: any, analysis: any): any {
|
||||||
|
try {
|
||||||
|
const originalAmount = debt.amount || 0;
|
||||||
|
let acceptedAmount = originalAmount;
|
||||||
|
let paymentStructure = null;
|
||||||
|
let financialBenefit = null;
|
||||||
|
|
||||||
|
// Try to extract accepted amount from AI analysis
|
||||||
|
if (analysis.extractedTerms?.proposedAmount) {
|
||||||
|
acceptedAmount = analysis.extractedTerms.proposedAmount;
|
||||||
|
} else if (analysis.extractedTerms?.paymentTerms?.totalAmount) {
|
||||||
|
acceptedAmount = analysis.extractedTerms.paymentTerms.totalAmount;
|
||||||
|
} else if (debt.metadata?.prospectedSavings?.amount) {
|
||||||
|
// Fall back to original negotiation terms if no specific amount mentioned
|
||||||
|
acceptedAmount = originalAmount - debt.metadata.prospectedSavings.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze payment structure if present
|
||||||
|
if (analysis.extractedTerms?.paymentTerms) {
|
||||||
|
const terms = analysis.extractedTerms.paymentTerms;
|
||||||
|
paymentStructure = {
|
||||||
|
type: "installment_plan",
|
||||||
|
monthlyAmount: terms.monthlyAmount,
|
||||||
|
numberOfPayments: terms.numberOfPayments,
|
||||||
|
totalAmount: terms.totalAmount || acceptedAmount,
|
||||||
|
frequency: terms.paymentFrequency || "monthly",
|
||||||
|
interestRate: terms.interestRate || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate time value and cash flow benefits
|
||||||
|
if (terms.monthlyAmount && terms.numberOfPayments) {
|
||||||
|
const totalPayments = terms.monthlyAmount * terms.numberOfPayments;
|
||||||
|
const timeToComplete = terms.numberOfPayments; // in months
|
||||||
|
|
||||||
|
financialBenefit = {
|
||||||
|
type: "payment_restructuring",
|
||||||
|
principalReduction: Math.max(0, originalAmount - totalPayments),
|
||||||
|
cashFlowRelief: {
|
||||||
|
monthlyReduction: originalAmount - terms.monthlyAmount,
|
||||||
|
extendedTermMonths: timeToComplete,
|
||||||
|
totalCashFlowBenefit: (originalAmount - terms.monthlyAmount) *
|
||||||
|
timeToComplete,
|
||||||
|
},
|
||||||
|
timeValueBenefit: calculateTimeValueBenefit(
|
||||||
|
originalAmount,
|
||||||
|
terms.monthlyAmount,
|
||||||
|
timeToComplete,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate actual savings (principal reduction)
|
||||||
|
const actualSavings = Math.max(0, originalAmount - acceptedAmount);
|
||||||
|
|
||||||
|
// Determine the primary financial benefit
|
||||||
|
if (actualSavings > 0) {
|
||||||
|
financialBenefit = {
|
||||||
|
type: "principal_reduction",
|
||||||
|
amount: actualSavings,
|
||||||
|
percentage: (actualSavings / originalAmount * 100).toFixed(2),
|
||||||
|
description: `${
|
||||||
|
((actualSavings / originalAmount) * 100).toFixed(1)
|
||||||
|
}% principal reduction`,
|
||||||
|
};
|
||||||
|
} else if (paymentStructure && paymentStructure.monthlyAmount) {
|
||||||
|
// No principal reduction but payment restructuring
|
||||||
|
const monthlyReduction = originalAmount - paymentStructure.monthlyAmount;
|
||||||
|
financialBenefit = {
|
||||||
|
type: "payment_restructuring",
|
||||||
|
monthlyReduction: monthlyReduction,
|
||||||
|
extendedTermMonths: paymentStructure.numberOfPayments,
|
||||||
|
description:
|
||||||
|
`Payment restructured to $${paymentStructure.monthlyAmount}/month over ${paymentStructure.numberOfPayments} months`,
|
||||||
|
cashFlowBenefit: monthlyReduction > 0
|
||||||
|
? `$${monthlyReduction}/month cash flow relief`
|
||||||
|
: "Extended payment terms",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Financial outcome: Original: $${originalAmount}, Accepted: $${acceptedAmount}, Savings: $${actualSavings}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
actualSavings,
|
||||||
|
acceptedAmount,
|
||||||
|
paymentStructure,
|
||||||
|
financialBenefit,
|
||||||
|
originalAmount,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error calculating financial outcome:", error);
|
||||||
|
// Return basic fallback
|
||||||
|
return {
|
||||||
|
actualSavings: debt.projected_savings || 0,
|
||||||
|
acceptedAmount: debt.amount,
|
||||||
|
paymentStructure: null,
|
||||||
|
financialBenefit: null,
|
||||||
|
originalAmount: debt.amount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate time value benefit of extended payment terms
|
||||||
|
function calculateTimeValueBenefit(
|
||||||
|
originalAmount: number,
|
||||||
|
monthlyPayment: number,
|
||||||
|
months: number,
|
||||||
|
): any {
|
||||||
|
// Simple present value calculation assuming 5% annual discount rate
|
||||||
|
const monthlyRate = 0.05 / 12;
|
||||||
|
let presentValue = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i <= months; i++) {
|
||||||
|
presentValue += monthlyPayment / Math.pow(1 + monthlyRate, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeValueBenefit = originalAmount - presentValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
presentValueOfPayments: presentValue.toFixed(2),
|
||||||
|
timeValueBenefit: timeValueBenefit.toFixed(2),
|
||||||
|
effectiveDiscount: ((timeValueBenefit / originalAmount) * 100).toFixed(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback analysis when AI is unavailable
|
||||||
|
function getFallbackAnalysis(
|
||||||
|
body: string,
|
||||||
|
): typeof responseAnalysisSchema._type {
|
||||||
|
const lowerBody = body.toLowerCase();
|
||||||
|
|
||||||
|
// Simple keyword-based analysis
|
||||||
|
let intent:
|
||||||
|
| "acceptance"
|
||||||
|
| "rejection"
|
||||||
|
| "counter_offer"
|
||||||
|
| "request_info"
|
||||||
|
| "unclear" = "unclear";
|
||||||
|
let sentiment: "positive" | "negative" | "neutral" = "neutral";
|
||||||
|
|
||||||
|
if (
|
||||||
|
lowerBody.includes("accept") || lowerBody.includes("agree") ||
|
||||||
|
lowerBody.includes("approved")
|
||||||
|
) {
|
||||||
|
intent = "acceptance";
|
||||||
|
sentiment = "positive";
|
||||||
|
} else if (
|
||||||
|
lowerBody.includes("reject") || lowerBody.includes("decline") ||
|
||||||
|
lowerBody.includes("denied")
|
||||||
|
) {
|
||||||
|
intent = "rejection";
|
||||||
|
sentiment = "negative";
|
||||||
|
} else if (
|
||||||
|
lowerBody.includes("counter") || lowerBody.includes("instead") ||
|
||||||
|
lowerBody.includes("however")
|
||||||
|
) {
|
||||||
|
intent = "counter_offer";
|
||||||
|
sentiment = "neutral";
|
||||||
|
} else if (
|
||||||
|
lowerBody.includes("information") || lowerBody.includes("clarify") ||
|
||||||
|
lowerBody.includes("details")
|
||||||
|
) {
|
||||||
|
intent = "request_info";
|
||||||
|
sentiment = "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced extraction using multiple regex patterns
|
||||||
|
const extractFinancialTerms = (text: string) => {
|
||||||
|
// Extract dollar amounts
|
||||||
|
const amountMatches = text.match(/\$(\d+(?:,\d{3})*(?:\.\d{2})?)/g);
|
||||||
|
const amounts =
|
||||||
|
amountMatches?.map((match) => parseFloat(match.replace(/[$,]/g, ""))) ||
|
||||||
|
[];
|
||||||
|
|
||||||
|
// Extract monthly payment patterns
|
||||||
|
const monthlyMatch = text.match(
|
||||||
|
/\$?(\d+(?:,\d{3})*(?:\.\d{2})?)\s*(?:per month|\/month|monthly)/i,
|
||||||
|
);
|
||||||
|
const monthlyAmount = monthlyMatch
|
||||||
|
? parseFloat(monthlyMatch[1].replace(/,/g, ""))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Extract number of payments/months
|
||||||
|
const paymentsMatch = text.match(
|
||||||
|
/(\d+)\s*(?:months?|payments?|installments?)/i,
|
||||||
|
);
|
||||||
|
const numberOfPayments = paymentsMatch
|
||||||
|
? parseInt(paymentsMatch[1])
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Extract total amount patterns
|
||||||
|
const totalMatch = text.match(
|
||||||
|
/(?:total|totaling|total amount)\s*(?:of\s*)?\$?(\d+(?:,\d{3})*(?:\.\d{2})?)/i,
|
||||||
|
);
|
||||||
|
const totalAmount = totalMatch
|
||||||
|
? parseFloat(totalMatch[1].replace(/,/g, ""))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Determine payment frequency
|
||||||
|
let paymentFrequency = undefined;
|
||||||
|
if (text.match(/monthly|per month|\/month/i)) paymentFrequency = "monthly";
|
||||||
|
else if (text.match(/weekly|per week|\/week/i)) paymentFrequency = "weekly";
|
||||||
|
else if (text.match(/bi-weekly|biweekly|every two weeks/i)) {
|
||||||
|
paymentFrequency = "bi-weekly";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
proposedAmount: amounts.length > 0 ? amounts[0] : undefined,
|
||||||
|
monthlyAmount,
|
||||||
|
numberOfPayments,
|
||||||
|
totalAmount,
|
||||||
|
paymentFrequency,
|
||||||
|
allAmounts: amounts,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const terms = extractFinancialTerms(body);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Fallback Analysis - Extracted Terms:",
|
||||||
|
JSON.stringify(terms, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
intent,
|
||||||
|
sentiment,
|
||||||
|
confidence: 0.6, // Lower confidence for fallback
|
||||||
|
extractedTerms: {
|
||||||
|
proposedAmount: terms.proposedAmount,
|
||||||
|
proposedPaymentPlan: terms.monthlyAmount ? "payment plan" : undefined,
|
||||||
|
paymentTerms: (terms.monthlyAmount || terms.numberOfPayments)
|
||||||
|
? {
|
||||||
|
monthlyAmount: terms.monthlyAmount,
|
||||||
|
numberOfPayments: terms.numberOfPayments,
|
||||||
|
totalAmount: terms.totalAmount,
|
||||||
|
paymentFrequency: terms.paymentFrequency,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
deadline: undefined,
|
||||||
|
conditions: [],
|
||||||
|
},
|
||||||
|
reasoning:
|
||||||
|
`Generated using enhanced keyword-based fallback analysis. Found ${terms.allAmounts.length} amounts.`,
|
||||||
|
suggestedNextAction: intent === "acceptance"
|
||||||
|
? "mark_settled"
|
||||||
|
: intent === "rejection"
|
||||||
|
? "escalate_to_user"
|
||||||
|
: intent === "counter_offer"
|
||||||
|
? "send_counter"
|
||||||
|
: "escalate_to_user",
|
||||||
|
requiresUserReview: true, // Always require review for fallback
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Fallback Analysis Result:", JSON.stringify(result, null, 2));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
serve(async (req) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabaseClient = createClient(
|
||||||
|
Deno.env.get("SUPABASE_URL") ?? "",
|
||||||
|
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
|
||||||
|
{
|
||||||
|
global: {
|
||||||
|
headers: { Authorization: req.headers.get("Authorization")! },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { debtId, fromEmail, subject, body, messageId }: EmailResponseData =
|
||||||
|
await req.json();
|
||||||
|
|
||||||
|
if (!debtId || !fromEmail || !body) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Missing required fields: debtId, fromEmail, body",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the debt record and original negotiation context
|
||||||
|
const { data: debt, error: debtError } = await supabaseClient
|
||||||
|
.from("debts")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", debtId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (debtError || !debt) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Debt record not found" }),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze the response using AI
|
||||||
|
const analysis = await analyzeEmailResponse(
|
||||||
|
supabaseClient,
|
||||||
|
debtId,
|
||||||
|
fromEmail,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
debt.metadata?.aiEmail,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log({ analysis });
|
||||||
|
|
||||||
|
// Store the conversation message
|
||||||
|
const { error: messageError } = await supabaseClient
|
||||||
|
.from("conversation_messages")
|
||||||
|
.update({
|
||||||
|
debt_id: debtId,
|
||||||
|
message_type: analysis.intent === "acceptance"
|
||||||
|
? "acceptance"
|
||||||
|
: analysis.intent === "rejection"
|
||||||
|
? "rejection"
|
||||||
|
: analysis.intent === "counter_offer"
|
||||||
|
? "counter_offer"
|
||||||
|
: "response_received",
|
||||||
|
direction: "inbound",
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
from_email: fromEmail,
|
||||||
|
to_email: debt.metadata?.toEmail || debt.metadata?.fromEmail,
|
||||||
|
ai_analysis: analysis,
|
||||||
|
})
|
||||||
|
.eq("message_id", messageId);
|
||||||
|
|
||||||
|
if (messageError) {
|
||||||
|
console.error("Error storing conversation message:", messageError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine next status and actions based on analysis
|
||||||
|
let newStatus = debt.status;
|
||||||
|
let newNegotiationRound = debt.negotiation_round || 1;
|
||||||
|
let financialOutcome = null;
|
||||||
|
let shouldAutoRespond = false;
|
||||||
|
let nextAction = null;
|
||||||
|
|
||||||
|
switch (analysis.intent) {
|
||||||
|
case "acceptance":
|
||||||
|
newStatus = "accepted";
|
||||||
|
nextAction = "mark_settled";
|
||||||
|
// Calculate financial outcome when offer is accepted
|
||||||
|
financialOutcome = calculateFinancialOutcome(debt, analysis);
|
||||||
|
break;
|
||||||
|
case "rejection":
|
||||||
|
newStatus = "rejected";
|
||||||
|
nextAction = "escalate_to_user";
|
||||||
|
break;
|
||||||
|
case "counter_offer":
|
||||||
|
newStatus = "counter_negotiating";
|
||||||
|
newNegotiationRound += 1;
|
||||||
|
// More conservative auto-response logic for counter-offers
|
||||||
|
shouldAutoRespond = !analysis.requiresUserReview &&
|
||||||
|
analysis.confidence > 0.9 && // Increased confidence threshold
|
||||||
|
analysis.extractedTerms && // Must have specific terms
|
||||||
|
(analysis.extractedTerms.proposedAmount ||
|
||||||
|
analysis.extractedTerms.paymentTerms) && // Must have concrete financial terms
|
||||||
|
body.length > 20; // Must be more than a vague message
|
||||||
|
nextAction = analysis.suggestedNextAction;
|
||||||
|
break;
|
||||||
|
case "request_info":
|
||||||
|
newStatus = "requires_manual_review";
|
||||||
|
nextAction = "escalate_to_user";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
newStatus = "requires_manual_review";
|
||||||
|
nextAction = "escalate_to_user";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update debt record
|
||||||
|
const updateData: any = {
|
||||||
|
status: newStatus,
|
||||||
|
negotiation_round: newNegotiationRound,
|
||||||
|
conversation_count: (debt.conversation_count || 0) + 1,
|
||||||
|
last_message_at: new Date().toISOString(),
|
||||||
|
metadata: {
|
||||||
|
...debt.metadata,
|
||||||
|
lastResponse: {
|
||||||
|
analysis,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
fromEmail,
|
||||||
|
subject,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add financial outcome if offer was accepted
|
||||||
|
if (analysis.intent === "acceptance" && financialOutcome) {
|
||||||
|
updateData.actual_savings = financialOutcome.actualSavings;
|
||||||
|
updateData.status = "settled"; // Set final status here instead of separate update
|
||||||
|
updateData.metadata.financialOutcome = {
|
||||||
|
...financialOutcome,
|
||||||
|
calculatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep backward compatibility with actualSavings field
|
||||||
|
updateData.metadata.actualSavings = {
|
||||||
|
amount: financialOutcome.actualSavings,
|
||||||
|
calculatedAt: new Date().toISOString(),
|
||||||
|
originalAmount: financialOutcome.originalAmount,
|
||||||
|
acceptedAmount: financialOutcome.acceptedAmount,
|
||||||
|
savingsPercentage: financialOutcome.originalAmount > 0
|
||||||
|
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
|
||||||
|
100).toFixed(2)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabaseClient
|
||||||
|
.from("debts")
|
||||||
|
.update(updateData)
|
||||||
|
.eq("id", debtId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error("Error updating debt:", updateError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the action
|
||||||
|
const auditDetails: any = {
|
||||||
|
intent: analysis.intent,
|
||||||
|
sentiment: analysis.sentiment,
|
||||||
|
confidence: analysis.confidence,
|
||||||
|
fromEmail,
|
||||||
|
subject,
|
||||||
|
suggestedAction: analysis.suggestedNextAction,
|
||||||
|
requiresReview: analysis.requiresUserReview,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add financial outcome to audit log if offer was accepted
|
||||||
|
if (analysis.intent === "acceptance" && financialOutcome) {
|
||||||
|
auditDetails.financialOutcome = financialOutcome;
|
||||||
|
auditDetails.actualSavings = financialOutcome.actualSavings;
|
||||||
|
auditDetails.originalAmount = financialOutcome.originalAmount;
|
||||||
|
auditDetails.acceptedAmount = financialOutcome.acceptedAmount;
|
||||||
|
auditDetails.savingsPercentage = financialOutcome.originalAmount > 0
|
||||||
|
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
|
||||||
|
100).toFixed(2)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabaseClient
|
||||||
|
.from("audit_logs")
|
||||||
|
.insert({
|
||||||
|
debt_id: debtId,
|
||||||
|
action: analysis.intent === "acceptance"
|
||||||
|
? "offer_accepted"
|
||||||
|
: "response_analyzed",
|
||||||
|
details: {
|
||||||
|
...auditDetails,
|
||||||
|
nextAction,
|
||||||
|
shouldAutoRespond,
|
||||||
|
negotiationRound: newNegotiationRound,
|
||||||
|
reasoning: analysis.reasoning,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If this is an acceptance, mark as settled
|
||||||
|
if (analysis.intent === "acceptance") {
|
||||||
|
// await supabaseClient
|
||||||
|
// .from("debts")
|
||||||
|
// .update({ status: "settled" })
|
||||||
|
// .eq("id", debtId);
|
||||||
|
|
||||||
|
await supabaseClient.from("audit_logs").insert({
|
||||||
|
debt_id: debtId,
|
||||||
|
action: "debt_settled",
|
||||||
|
details: {
|
||||||
|
finalAmount: financialOutcome?.acceptedAmount || debt.amount,
|
||||||
|
actualSavings: financialOutcome?.actualSavings || 0,
|
||||||
|
settlementTerms: analysis.extractedTerms,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
shouldAutoRespond,
|
||||||
|
analysisIntent: analysis.intent,
|
||||||
|
analysisConfidence: analysis.confidence,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TEMPORARILY DISABLED: Auto-responses need more testing
|
||||||
|
// TODO: Re-enable after thorough testing and user preference settings
|
||||||
|
const AUTO_RESPONSES_ENABLED = true;
|
||||||
|
const conversationHistory = await getConversationHistory(
|
||||||
|
supabaseClient,
|
||||||
|
debtId,
|
||||||
|
);
|
||||||
|
console.log({
|
||||||
|
conversationHistoryLength: conversationHistory.length,
|
||||||
|
});
|
||||||
|
// If auto-response is recommended and confidence is high, trigger negotiation
|
||||||
|
if (
|
||||||
|
AUTO_RESPONSES_ENABLED &&
|
||||||
|
shouldAutoRespond && analysis.confidence > 0.8 &&
|
||||||
|
analysis.intent === "counter_offer" &&
|
||||||
|
// the length of the conversation isn't bigger than 2 messages
|
||||||
|
conversationHistory.length <= 2
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const negotiateUrl = `${
|
||||||
|
Deno.env.get("SUPABASE_URL")
|
||||||
|
}/functions/v1/negotiate`;
|
||||||
|
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||||
|
|
||||||
|
if (negotiateUrl && serviceKey) {
|
||||||
|
await fetch(negotiateUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${serviceKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
record: {
|
||||||
|
...debt,
|
||||||
|
status: newStatus,
|
||||||
|
conversation_count: (debt.conversation_count || 0) + 1,
|
||||||
|
negotiation_round: newNegotiationRound,
|
||||||
|
},
|
||||||
|
counterOfferContext: {
|
||||||
|
previousResponse: body,
|
||||||
|
extractedTerms: analysis.extractedTerms,
|
||||||
|
sentiment: analysis.sentiment,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await supabaseClient.from("audit_logs").insert({
|
||||||
|
debt_id: debtId,
|
||||||
|
action: "auto_counter_triggered",
|
||||||
|
details: {
|
||||||
|
confidence: analysis.confidence,
|
||||||
|
extractedTerms: analysis.extractedTerms,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (autoResponseError) {
|
||||||
|
console.error("Error triggering auto-response:", autoResponseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData: any = {
|
||||||
|
success: true,
|
||||||
|
analysis,
|
||||||
|
newStatus,
|
||||||
|
negotiationRound: newNegotiationRound,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include financial outcome in response if offer was accepted
|
||||||
|
if (analysis.intent === "acceptance" && financialOutcome) {
|
||||||
|
responseData.financialOutcome = financialOutcome;
|
||||||
|
responseData.actualSavings = financialOutcome.actualSavings;
|
||||||
|
responseData.savingsCalculated = true;
|
||||||
|
responseData.originalAmount = financialOutcome.originalAmount;
|
||||||
|
responseData.acceptedAmount = financialOutcome.acceptedAmount;
|
||||||
|
responseData.savingsPercentage = financialOutcome.originalAmount > 0
|
||||||
|
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
|
||||||
|
100).toFixed(2)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify(responseData),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in analyze-response function:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Internal server error" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,854 +1,25 @@
|
|||||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
|
||||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
|
||||||
import { generateObject } from "https://esm.sh/ai@3.4.7";
|
|
||||||
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
|
|
||||||
import { z } from "https://esm.sh/zod@3.22.4";
|
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Headers":
|
"Access-Control-Allow-Headers":
|
||||||
"authorization, x-client-info, apikey, content-type",
|
"authorization, x-client-info, apikey, content-type",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Schema for AI response analysis
|
Deno.serve(async (req) => {
|
||||||
const responseAnalysisSchema = z.object({
|
// Handle CORS preflight requests
|
||||||
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") {
|
if (req.method === "OPTIONS") {
|
||||||
return new Response("ok", { headers: corsHeaders });
|
return new Response("ok", { headers: corsHeaders });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Project has been disabled
|
||||||
const supabaseClient = createClient(
|
return new Response(
|
||||||
Deno.env.get("SUPABASE_URL") ?? "",
|
JSON.stringify({
|
||||||
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
|
error: "Project Disabled",
|
||||||
{
|
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
|
||||||
global: {
|
}),
|
||||||
headers: { Authorization: req.headers.get("Authorization")! },
|
{
|
||||||
},
|
status: 503,
|
||||||
},
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
);
|
|
||||||
|
|
||||||
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" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
235
supabase/functions/approve-debt/index-original.ts
Normal file
235
supabase/functions/approve-debt/index-original.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/*
|
||||||
|
# Debt Approval Edge Function
|
||||||
|
|
||||||
|
This function handles debt approval without sending emails:
|
||||||
|
- Updates debt status to "approved"
|
||||||
|
- Logs the approval action
|
||||||
|
- Saves finalized negotiation data
|
||||||
|
- Updates metadata with approval timestamp
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers":
|
||||||
|
"authorization, x-client-info, apikey, content-type",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ApproveDebtRequest {
|
||||||
|
debtId: string;
|
||||||
|
approvalNote?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DebtRecord {
|
||||||
|
id: string;
|
||||||
|
vendor: string;
|
||||||
|
amount: number;
|
||||||
|
raw_email: string;
|
||||||
|
negotiated_plan?: string;
|
||||||
|
status: string;
|
||||||
|
metadata?: {
|
||||||
|
aiEmail?: {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
strategy: string;
|
||||||
|
};
|
||||||
|
fromEmail?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
// Handle CORS preflight requests
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Method not allowed" }),
|
||||||
|
{
|
||||||
|
status: 405,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Supabase client with auth context
|
||||||
|
const supabaseClient = createClient(
|
||||||
|
Deno.env.get("SUPABASE_URL") ?? "",
|
||||||
|
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
|
||||||
|
{
|
||||||
|
global: {
|
||||||
|
headers: { Authorization: req.headers.get("Authorization") ?? "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the authenticated user
|
||||||
|
const authHeader = req.headers.get("Authorization");
|
||||||
|
if (!authHeader) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Authorization header required" }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.replace("Bearer ", "");
|
||||||
|
const { data: userData } = await supabaseClient.auth.getUser(token);
|
||||||
|
const user = userData.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Unauthorized" }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { debtId, approvalNote }: ApproveDebtRequest = await req.json();
|
||||||
|
|
||||||
|
if (!debtId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Missing debtId" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch debt record - RLS will ensure user can only access their own debts
|
||||||
|
const { data: debtRecord, error: debtError } = await supabaseClient
|
||||||
|
.from("debts")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", debtId)
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (debtError || !debtRecord) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Debt not found or access denied" }),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const debt = debtRecord as DebtRecord;
|
||||||
|
|
||||||
|
// Validate that the debt is in negotiating status
|
||||||
|
if (debt.status !== "negotiating") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Debt is not in negotiating status",
|
||||||
|
currentStatus: debt.status,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that AI email exists
|
||||||
|
if (!debt.metadata?.aiEmail) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "No AI-generated email found for this debt" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTimestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
// Update debt status to approved - using authenticated client
|
||||||
|
const { error: updateError } = await supabaseClient
|
||||||
|
.from("debts")
|
||||||
|
.update({
|
||||||
|
status: "approved",
|
||||||
|
metadata: {
|
||||||
|
...debt.metadata,
|
||||||
|
approved: {
|
||||||
|
approvedAt: currentTimestamp,
|
||||||
|
approvalNote: approvalNote || "Approved without sending email",
|
||||||
|
strategy: debt.metadata.aiEmail.strategy,
|
||||||
|
finalizedSubject: debt.metadata.aiEmail.subject,
|
||||||
|
finalizedBody: debt.metadata.aiEmail.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.eq("id", debtId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error("Error updating debt status:", updateError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to update debt status",
|
||||||
|
details: updateError.message,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the approval action - using authenticated client
|
||||||
|
const { error: auditError } = await supabaseClient
|
||||||
|
.from("audit_logs")
|
||||||
|
.insert({
|
||||||
|
debt_id: debtId,
|
||||||
|
action: "debt_approved",
|
||||||
|
details: {
|
||||||
|
approvedAt: currentTimestamp,
|
||||||
|
approvalNote: approvalNote || "Approved without sending email",
|
||||||
|
strategy: debt.metadata.aiEmail.strategy,
|
||||||
|
subject: debt.metadata.aiEmail.subject,
|
||||||
|
vendor: debt.vendor,
|
||||||
|
amount: debt.amount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auditError) {
|
||||||
|
console.warn("Failed to log approval action:", auditError);
|
||||||
|
// Don't fail the entire operation for audit log issues
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
debtId: debtId,
|
||||||
|
status: "approved",
|
||||||
|
approvedAt: currentTimestamp,
|
||||||
|
vendor: debt.vendor,
|
||||||
|
amount: debt.amount,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in approve-debt function:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Internal server error",
|
||||||
|
message: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -8,8 +8,6 @@
|
|||||||
- Updates metadata with approval timestamp
|
- Updates metadata with approval timestamp
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Headers":
|
"Access-Control-Allow-Headers":
|
||||||
@@ -17,219 +15,21 @@ const corsHeaders = {
|
|||||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
"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) => {
|
Deno.serve(async (req) => {
|
||||||
// Handle CORS preflight requests
|
// Handle CORS preflight requests
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
return new Response("ok", { headers: corsHeaders });
|
return new Response("ok", { headers: corsHeaders });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Project has been disabled
|
||||||
if (req.method !== "POST") {
|
return new Response(
|
||||||
return new Response(
|
JSON.stringify({
|
||||||
JSON.stringify({ error: "Method not allowed" }),
|
error: "Project Disabled",
|
||||||
{
|
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
|
||||||
status: 405,
|
}),
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
{
|
||||||
},
|
status: 503,
|
||||||
);
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
|
);
|
||||||
// Initialize Supabase client with auth context
|
});
|
||||||
const supabaseClient = createClient(
|
|
||||||
Deno.env.get("SUPABASE_URL") ?? "",
|
|
||||||
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
|
|
||||||
{
|
|
||||||
global: {
|
|
||||||
headers: { Authorization: req.headers.get("Authorization") ?? "" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get the authenticated user
|
|
||||||
const authHeader = req.headers.get("Authorization");
|
|
||||||
if (!authHeader) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: "Authorization header required" }),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.replace("Bearer ", "");
|
|
||||||
const { data: userData } = await supabaseClient.auth.getUser(token);
|
|
||||||
const user = userData.user;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: "Unauthorized" }),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { debtId, approvalNote }: ApproveDebtRequest = await req.json();
|
|
||||||
|
|
||||||
if (!debtId) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: "Missing debtId" }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch debt record - RLS will ensure user can only access their own debts
|
|
||||||
const { data: debtRecord, error: debtError } = await supabaseClient
|
|
||||||
.from("debts")
|
|
||||||
.select("*")
|
|
||||||
.eq("id", debtId)
|
|
||||||
.eq("user_id", user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (debtError || !debtRecord) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: "Debt not found or access denied" }),
|
|
||||||
{
|
|
||||||
status: 404,
|
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const debt = debtRecord as DebtRecord;
|
|
||||||
|
|
||||||
// Validate that the debt is in negotiating status
|
|
||||||
if (debt.status !== "negotiating") {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: "Debt is not in negotiating status",
|
|
||||||
currentStatus: debt.status,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that AI email exists
|
|
||||||
if (!debt.metadata?.aiEmail) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: "No AI-generated email found for this debt" }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTimestamp = new Date().toISOString();
|
|
||||||
|
|
||||||
// Update debt status to approved - using authenticated client
|
|
||||||
const { error: updateError } = await supabaseClient
|
|
||||||
.from("debts")
|
|
||||||
.update({
|
|
||||||
status: "approved",
|
|
||||||
metadata: {
|
|
||||||
...debt.metadata,
|
|
||||||
approved: {
|
|
||||||
approvedAt: currentTimestamp,
|
|
||||||
approvalNote: approvalNote || "Approved without sending email",
|
|
||||||
strategy: debt.metadata.aiEmail.strategy,
|
|
||||||
finalizedSubject: debt.metadata.aiEmail.subject,
|
|
||||||
finalizedBody: debt.metadata.aiEmail.body,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.eq("id", debtId);
|
|
||||||
|
|
||||||
if (updateError) {
|
|
||||||
console.error("Error updating debt status:", updateError);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: "Failed to update debt status",
|
|
||||||
details: updateError.message,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the approval action - using authenticated client
|
|
||||||
const { error: auditError } = await supabaseClient
|
|
||||||
.from("audit_logs")
|
|
||||||
.insert({
|
|
||||||
debt_id: debtId,
|
|
||||||
action: "debt_approved",
|
|
||||||
details: {
|
|
||||||
approvedAt: currentTimestamp,
|
|
||||||
approvalNote: approvalNote || "Approved without sending email",
|
|
||||||
strategy: debt.metadata.aiEmail.strategy,
|
|
||||||
subject: debt.metadata.aiEmail.subject,
|
|
||||||
vendor: debt.vendor,
|
|
||||||
amount: debt.amount,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (auditError) {
|
|
||||||
console.warn("Failed to log approval action:", auditError);
|
|
||||||
// Don't fail the entire operation for audit log issues
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
debtId: debtId,
|
|
||||||
status: "approved",
|
|
||||||
approvedAt: currentTimestamp,
|
|
||||||
vendor: debt.vendor,
|
|
||||||
amount: debt.amount,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in approve-debt function:", error);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: "Internal server error",
|
|
||||||
message: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
635
supabase/functions/negotiate/index-original.ts
Normal file
635
supabase/functions/negotiate/index-original.ts
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
/*
|
||||||
|
# Debt Negotiation AI Edge Function
|
||||||
|
|
||||||
|
This function generates FDCPA-compliant negotiation responses using AI analysis:
|
||||||
|
- Analyzes debt details and vendor information
|
||||||
|
- Generates personalized negotiation strategies
|
||||||
|
- Creates contextually appropriate response letters
|
||||||
|
- Ensures FDCPA compliance
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||||
|
import { generateObject } from "npm:ai@4.3.16";
|
||||||
|
import { createGoogleGenerativeAI } from "npm:@ai-sdk/google@1.2.19";
|
||||||
|
import { z } from "npm:zod@3.23.8";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers":
|
||||||
|
"authorization, x-client-info, apikey, content-type",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Schema for AI-generated negotiation email
|
||||||
|
const negotiationEmailSchema = z.object({
|
||||||
|
subject: z.string().describe("The email subject line"),
|
||||||
|
body: z.string().describe(
|
||||||
|
"The complete email body text with proper formatting and placeholders for missing data",
|
||||||
|
),
|
||||||
|
strategy: z.enum(["extension", "installment", "settlement", "dispute"])
|
||||||
|
.describe("The recommended negotiation strategy"),
|
||||||
|
confidenceLevel: z.number().min(0).max(1).describe(
|
||||||
|
"Confidence in the strategy recommendation",
|
||||||
|
),
|
||||||
|
projectedSavings: z.number().min(0).describe(
|
||||||
|
"Estimated savings from this strategy",
|
||||||
|
),
|
||||||
|
reasoning: z.string().describe("Explanation of why this strategy was chosen"),
|
||||||
|
customTerms: z.object({
|
||||||
|
extensionDays: z.number().optional().describe(
|
||||||
|
"Days for extension if applicable",
|
||||||
|
),
|
||||||
|
installmentMonths: z.number().optional().describe(
|
||||||
|
"Number of months for installment plan",
|
||||||
|
),
|
||||||
|
settlementPercentage: z.number().optional().describe(
|
||||||
|
"Settlement percentage (0-1) if applicable",
|
||||||
|
),
|
||||||
|
monthlyPayment: z.number().optional().describe(
|
||||||
|
"Monthly payment amount for installments",
|
||||||
|
),
|
||||||
|
}).describe("Custom terms based on the strategy"),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface PersonalData {
|
||||||
|
full_name?: string;
|
||||||
|
address_line_1?: string;
|
||||||
|
address_line_2?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip_code?: string;
|
||||||
|
phone_number?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DebtRecord {
|
||||||
|
id: string;
|
||||||
|
vendor: string;
|
||||||
|
amount: number;
|
||||||
|
raw_email: string;
|
||||||
|
description?: string;
|
||||||
|
due_date?: string;
|
||||||
|
user_id?: string;
|
||||||
|
metadata?: {
|
||||||
|
isDebtCollection?: boolean;
|
||||||
|
subject?: string;
|
||||||
|
fromEmail?: string;
|
||||||
|
toEmail?: string;
|
||||||
|
aiEmail?: {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
strategy: string;
|
||||||
|
confidence: number;
|
||||||
|
reasoning: string;
|
||||||
|
customTerms: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
lastResponse?: {
|
||||||
|
analysis: Record<string, unknown>;
|
||||||
|
receivedAt: string;
|
||||||
|
fromEmail: string;
|
||||||
|
subject: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CounterOfferContext {
|
||||||
|
previousResponse: string;
|
||||||
|
extractedTerms: {
|
||||||
|
proposedAmount?: number;
|
||||||
|
proposedPaymentPlan?: string;
|
||||||
|
monthlyAmount?: number;
|
||||||
|
numberOfPayments?: number;
|
||||||
|
totalAmount?: number;
|
||||||
|
paymentFrequency?: string;
|
||||||
|
};
|
||||||
|
sentiment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI-powered negotiation email generator
|
||||||
|
async function generateNegotiationEmail(
|
||||||
|
record: DebtRecord,
|
||||||
|
personalData: PersonalData,
|
||||||
|
counterOfferContext?: CounterOfferContext,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
|
||||||
|
if (!googleApiKey) {
|
||||||
|
console.warn("Google API key not configured, falling back to template");
|
||||||
|
return generateFallbackEmail(record, personalData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build context-aware system prompt
|
||||||
|
let systemPrompt =
|
||||||
|
`You are an expert debt negotiation advisor specializing in FDCPA-compliant email generation.
|
||||||
|
Create professional, formal negotiation emails that:
|
||||||
|
- Include appropriate subject line and email body
|
||||||
|
- Follow Fair Debt Collection Practices Act requirements
|
||||||
|
- Use the provided personal data in proper letter format
|
||||||
|
- Include specific negotiation terms based on debt amount
|
||||||
|
- Use {{ variable }} syntax for missing or uncertain data (like account numbers, specific dates)
|
||||||
|
- Maintain professional tone throughout
|
||||||
|
- Include proper business letter formatting
|
||||||
|
|
||||||
|
Strategy guidelines based on amount:
|
||||||
|
- Extension: For temporary hardship, usually < $500
|
||||||
|
- Installment: For manageable monthly payments, $500-$2000
|
||||||
|
- Settlement: For significant savings, typically $2000+
|
||||||
|
- Dispute: If debt validity is questionable
|
||||||
|
|
||||||
|
For missing personal data, use appropriate placeholders.
|
||||||
|
For uncertain information like account numbers, use {{ Account Number }} format.`;
|
||||||
|
|
||||||
|
// Build context-aware prompt
|
||||||
|
let prompt = `Generate a complete negotiation email for this debt:
|
||||||
|
|
||||||
|
Debt Amount: $${record.amount}
|
||||||
|
Vendor: ${record.vendor}
|
||||||
|
Description: ${record.description || "Not specified"}
|
||||||
|
Due Date: ${record.due_date || "Not specified"}
|
||||||
|
Email Content Preview: ${record.raw_email.substring(0, 500)}...
|
||||||
|
|
||||||
|
Personal Data Available:
|
||||||
|
- Full Name: ${personalData.full_name || "{{ Full Name }}"}
|
||||||
|
- Address: ${personalData.address_line_1 || "{{ Address Line 1 }}"} ${
|
||||||
|
personalData.address_line_2 ? personalData.address_line_2 : ""
|
||||||
|
}
|
||||||
|
- City: ${personalData.city || "{{ City }}"}
|
||||||
|
- State: ${personalData.state || "{{ State }}"}
|
||||||
|
- Zip: ${personalData.zip_code || "{{ Zip Code }}"}
|
||||||
|
- Phone: ${personalData.phone_number || "{{ Phone Number }}"}`;
|
||||||
|
|
||||||
|
// Add counter-offer context if this is a response to a creditor's counter-offer
|
||||||
|
if (counterOfferContext) {
|
||||||
|
systemPrompt += `
|
||||||
|
|
||||||
|
IMPORTANT: This is a COUNTER-RESPONSE to a creditor's previous response. You must:
|
||||||
|
- Acknowledge their previous response professionally
|
||||||
|
- Address their specific terms or concerns
|
||||||
|
- Make a strategic counter-offer that moves toward resolution
|
||||||
|
- Show willingness to negotiate while protecting the debtor's interests
|
||||||
|
- Reference specific amounts or terms they mentioned
|
||||||
|
- Maintain momentum in the negotiation process`;
|
||||||
|
|
||||||
|
prompt += `
|
||||||
|
|
||||||
|
CREDITOR'S PREVIOUS RESPONSE CONTEXT:
|
||||||
|
- Their Response: ${counterOfferContext.previousResponse}
|
||||||
|
- Sentiment: ${counterOfferContext.sentiment}
|
||||||
|
- Extracted Terms: ${JSON.stringify(counterOfferContext.extractedTerms)}
|
||||||
|
|
||||||
|
Generate a strategic counter-response that acknowledges their position and makes a reasonable counter-offer.`;
|
||||||
|
} else {
|
||||||
|
prompt += `
|
||||||
|
|
||||||
|
Create a professional initial negotiation email with subject and body.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log({ systemPrompt, prompt });
|
||||||
|
|
||||||
|
const result = await generateObject({
|
||||||
|
model: createGoogleGenerativeAI({
|
||||||
|
apiKey: googleApiKey,
|
||||||
|
})("gemini-2.5-flash-preview-04-17"),
|
||||||
|
system: systemPrompt,
|
||||||
|
prompt: prompt,
|
||||||
|
schema: negotiationEmailSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.object;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("AI email generation error:", error);
|
||||||
|
return generateFallbackEmail(record, personalData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback email generation when AI is unavailable
|
||||||
|
function generateFallbackEmail(record: DebtRecord, personalData: PersonalData) {
|
||||||
|
let strategy: "extension" | "installment" | "settlement" | "dispute" =
|
||||||
|
"extension";
|
||||||
|
let projectedSavings = 0;
|
||||||
|
let customTerms = {};
|
||||||
|
|
||||||
|
if (record.amount < 500) {
|
||||||
|
strategy = "extension";
|
||||||
|
projectedSavings = 0;
|
||||||
|
customTerms = { extensionDays: 30 };
|
||||||
|
} else if (record.amount >= 500 && record.amount < 2000) {
|
||||||
|
strategy = "installment";
|
||||||
|
projectedSavings = record.amount * 0.1;
|
||||||
|
customTerms = {
|
||||||
|
installmentMonths: 3,
|
||||||
|
monthlyPayment: Math.round(record.amount / 3 * 100) / 100,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
strategy = "settlement";
|
||||||
|
projectedSavings = record.amount * 0.4;
|
||||||
|
customTerms = { settlementPercentage: 0.6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject =
|
||||||
|
`Account Number: {{ Account Number }} - Payment Arrangement Request`;
|
||||||
|
const body = generateNegotiationLetter(record, strategy, personalData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
strategy,
|
||||||
|
confidenceLevel: 0.7,
|
||||||
|
projectedSavings,
|
||||||
|
reasoning: "Generated using rule-based fallback logic",
|
||||||
|
customTerms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate negotiation letter for fallback
|
||||||
|
function generateNegotiationLetter(
|
||||||
|
record: DebtRecord,
|
||||||
|
strategy: string,
|
||||||
|
personalData: PersonalData,
|
||||||
|
): string {
|
||||||
|
const senderInfo = `${personalData.full_name || "{{ Full Name }}"}
|
||||||
|
${personalData.address_line_1 || "{{ Address Line 1 }}"} ${
|
||||||
|
personalData.address_line_2 ? personalData.address_line_2 : ""
|
||||||
|
}
|
||||||
|
${personalData.city || "{{ City }}"}, ${personalData.state || "{{ State }}"} ${
|
||||||
|
personalData.zip_code || "{{ Zip Code }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
${personalData.phone_number || "{{ Phone Number }}"}
|
||||||
|
|
||||||
|
{{ Date }}`;
|
||||||
|
|
||||||
|
const vendorDomain = record.vendor.includes("@")
|
||||||
|
? record.vendor.split("@")[1]
|
||||||
|
: record.vendor;
|
||||||
|
const companyName = vendorDomain.split(".")[0].toUpperCase();
|
||||||
|
|
||||||
|
const recipientInfo = `${companyName} Collections Department
|
||||||
|
{{ Collection Agency Address }}`;
|
||||||
|
|
||||||
|
const baseResponse = `${senderInfo}
|
||||||
|
|
||||||
|
${recipientInfo}
|
||||||
|
|
||||||
|
Subject: Account Number: {{ Account Number }}
|
||||||
|
|
||||||
|
To Whom It May Concern,
|
||||||
|
|
||||||
|
This letter is regarding the debt associated with the account number referenced above, originally with ${record.vendor}, in the amount of $${
|
||||||
|
record.amount.toFixed(2)
|
||||||
|
}.
|
||||||
|
|
||||||
|
I am writing to propose a payment arrangement to resolve this matter.`;
|
||||||
|
|
||||||
|
let proposal = "";
|
||||||
|
|
||||||
|
switch (strategy) {
|
||||||
|
case "extension": {
|
||||||
|
proposal =
|
||||||
|
` I respectfully request a 30-day extension to arrange full payment. I anticipate being able to settle this account in full by {{ Proposed Payment Date }}.
|
||||||
|
|
||||||
|
During this extension period, I request that no additional fees or interest be applied to maintain the current balance.`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "installment": {
|
||||||
|
const monthlyPayment = (record.amount / 3).toFixed(2);
|
||||||
|
proposal = ` I am able to pay the full balance of $${
|
||||||
|
record.amount.toFixed(2)
|
||||||
|
} through an installment plan. I propose to make three (3) equal monthly payments of $${monthlyPayment}, with the first payment to be made on {{ Proposed Start Date }}.`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "settlement": {
|
||||||
|
const settlementAmount = (record.amount * 0.6).toFixed(2);
|
||||||
|
proposal =
|
||||||
|
` I would like to propose a lump-sum settlement offer of $${settlementAmount} (60% of the current balance) to resolve this matter completely.
|
||||||
|
|
||||||
|
This settlement would be paid within 10 business days of written acceptance of this offer. Upon payment, I request written confirmation that this account will be considered paid in full and closed.`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "dispute": {
|
||||||
|
proposal =
|
||||||
|
` I am formally disputing this debt and requesting validation under Section 809(b) of the Fair Debt Collection Practices Act.
|
||||||
|
|
||||||
|
Please provide:
|
||||||
|
- Verification of the debt amount
|
||||||
|
- Name and address of the original creditor
|
||||||
|
- Copy of any judgment (if applicable)
|
||||||
|
- Verification of your authority to collect this debt
|
||||||
|
|
||||||
|
Until proper validation is provided, I request that all collection activities cease.`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closingResponse = `
|
||||||
|
|
||||||
|
Please confirm in writing your acceptance of this installment plan. Upon receiving your written agreement, I will begin making the proposed payments according to the schedule.
|
||||||
|
|
||||||
|
In accordance with the Fair Debt Collection Practices Act (FDCPA), I request validation of this debt. Please provide verification of the debt, including documentation showing the original creditor, the amount owed, and that you are legally authorized to collect this debt. I understand that you must cease collection efforts until this validation is provided.
|
||||||
|
|
||||||
|
I look forward to your prompt response and confirmation of this payment arrangement.
|
||||||
|
|
||||||
|
Sincerely,
|
||||||
|
${personalData.full_name || "{{ Your Typed Name }}"}`;
|
||||||
|
|
||||||
|
return baseResponse + proposal + closingResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
// Handle CORS preflight requests
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project has been disabled
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Project Disabled",
|
||||||
|
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 503,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Method not allowed" }),
|
||||||
|
{
|
||||||
|
status: 405,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a webhook call (using service role) or authenticated user call
|
||||||
|
const authHeader = req.headers.get("Authorization");
|
||||||
|
const isServiceRoleCall = authHeader?.includes(
|
||||||
|
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
let user: { id: string } | null = null;
|
||||||
|
let supabaseClient;
|
||||||
|
|
||||||
|
if (isServiceRoleCall) {
|
||||||
|
// This is a webhook/service call - use admin client
|
||||||
|
supabaseClient = createClient(
|
||||||
|
Deno.env.get("SUPABASE_URL") ?? "",
|
||||||
|
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// For webhook calls, we'll get the userId from the request body along with the record
|
||||||
|
const { record, counterOfferContext }: {
|
||||||
|
record: DebtRecord;
|
||||||
|
counterOfferContext?: CounterOfferContext;
|
||||||
|
} = await req.json();
|
||||||
|
|
||||||
|
if (!record || !record.user_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Missing record or user_id for service call",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
user = { id: record.user_id };
|
||||||
|
|
||||||
|
// Use the record as-is for webhook calls
|
||||||
|
const personalData = await fetchUserPersonalData(supabaseClient, user.id);
|
||||||
|
return await processNegotiation(
|
||||||
|
supabaseClient,
|
||||||
|
record,
|
||||||
|
personalData,
|
||||||
|
counterOfferContext,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// This is an authenticated user call
|
||||||
|
if (!authHeader) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Authorization header required" }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Supabase client with auth context
|
||||||
|
supabaseClient = createClient(
|
||||||
|
Deno.env.get("SUPABASE_URL") ?? "",
|
||||||
|
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
|
||||||
|
{
|
||||||
|
global: {
|
||||||
|
headers: { Authorization: authHeader },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = authHeader.replace("Bearer ", "");
|
||||||
|
const { data: userData } = await supabaseClient.auth.getUser(token);
|
||||||
|
user = userData.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Unauthorized" }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { debtId }: { debtId: string } = await req.json();
|
||||||
|
|
||||||
|
if (!debtId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Missing debtId" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch debt record - RLS will ensure user can only access their own debts
|
||||||
|
const { data: debtRecord, error: debtError } = await supabaseClient
|
||||||
|
.from("debts")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", debtId)
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (debtError || !debtRecord) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Debt not found or access denied" }),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = debtRecord as DebtRecord;
|
||||||
|
const personalData = await fetchUserPersonalData(supabaseClient, user.id);
|
||||||
|
return await processNegotiation(supabaseClient, record, personalData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Negotiation function error:", error);
|
||||||
|
const errorMessage = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "An unknown error occurred" +
|
||||||
|
(Deno.env.get("NODE_ENV") === "development"
|
||||||
|
? `: ${JSON.stringify(error)}`
|
||||||
|
: "");
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: errorMessage }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to fetch user personal data
|
||||||
|
async function fetchUserPersonalData(
|
||||||
|
supabaseClient: ReturnType<typeof createClient>,
|
||||||
|
userId: string,
|
||||||
|
): Promise<PersonalData>;
|
||||||
|
async function fetchUserPersonalData(
|
||||||
|
supabaseClient: unknown,
|
||||||
|
userId: string,
|
||||||
|
): Promise<PersonalData>;
|
||||||
|
async function fetchUserPersonalData(
|
||||||
|
supabaseClient: unknown,
|
||||||
|
userId: string,
|
||||||
|
): Promise<PersonalData> {
|
||||||
|
const client = supabaseClient as ReturnType<typeof createClient>;
|
||||||
|
const { data: userPersonalData, error: userError } = await client
|
||||||
|
.from("users")
|
||||||
|
.select(
|
||||||
|
"full_name, address_line_1, address_line_2, city, state, zip_code, phone_number",
|
||||||
|
)
|
||||||
|
.eq("id", userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (userError) {
|
||||||
|
console.error("Error fetching user data:", userError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (userPersonalData as PersonalData) || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to process the negotiation
|
||||||
|
async function processNegotiation(
|
||||||
|
supabaseClient: ReturnType<typeof createClient>,
|
||||||
|
record: DebtRecord,
|
||||||
|
personalData: PersonalData,
|
||||||
|
counterOfferContext?: CounterOfferContext,
|
||||||
|
): Promise<Response>;
|
||||||
|
async function processNegotiation(
|
||||||
|
supabaseClient: unknown,
|
||||||
|
record: DebtRecord,
|
||||||
|
personalData: PersonalData,
|
||||||
|
counterOfferContext?: CounterOfferContext,
|
||||||
|
): Promise<Response>;
|
||||||
|
async function processNegotiation(
|
||||||
|
supabaseClient: unknown,
|
||||||
|
record: DebtRecord,
|
||||||
|
personalData: PersonalData,
|
||||||
|
counterOfferContext?: CounterOfferContext,
|
||||||
|
): Promise<Response> {
|
||||||
|
const client = supabaseClient as ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
// Generate AI-powered negotiation email
|
||||||
|
const emailResult = await generateNegotiationEmail(
|
||||||
|
record,
|
||||||
|
personalData,
|
||||||
|
counterOfferContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create conversation message only for auto-responses (counter-offers)
|
||||||
|
// Regular negotiation generation doesn't create messages since they're not sent yet
|
||||||
|
if (counterOfferContext) {
|
||||||
|
await client.from("conversation_messages").insert({
|
||||||
|
debt_id: record.id,
|
||||||
|
message_type: "counter_offer",
|
||||||
|
direction: "outbound",
|
||||||
|
subject: emailResult.subject,
|
||||||
|
body: emailResult.body,
|
||||||
|
from_email: record.metadata?.toEmail || "user@example.com",
|
||||||
|
to_email: record.metadata?.fromEmail || record.vendor,
|
||||||
|
message_id: `auto-counter-${Date.now()}`,
|
||||||
|
ai_analysis: {
|
||||||
|
strategy: emailResult.strategy,
|
||||||
|
confidence: emailResult.confidenceLevel,
|
||||||
|
projectedSavings: emailResult.projectedSavings,
|
||||||
|
isAutoGenerated: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update debt record with AI-generated content - using provided client
|
||||||
|
const { error: updateError } = await client
|
||||||
|
.from("debts")
|
||||||
|
.update({
|
||||||
|
negotiated_plan: `Subject: ${emailResult.subject}\n\n${emailResult.body}`,
|
||||||
|
projected_savings: emailResult.projectedSavings,
|
||||||
|
status: counterOfferContext ? "counter_negotiating" : "negotiating",
|
||||||
|
metadata: {
|
||||||
|
...record.metadata,
|
||||||
|
aiEmail: {
|
||||||
|
subject: emailResult.subject,
|
||||||
|
body: emailResult.body,
|
||||||
|
strategy: emailResult.strategy,
|
||||||
|
confidence: emailResult.confidenceLevel,
|
||||||
|
reasoning: emailResult.reasoning,
|
||||||
|
customTerms: emailResult.customTerms,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.eq("id", record.id);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
throw updateError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the action - using provided client
|
||||||
|
await client
|
||||||
|
.from("audit_logs")
|
||||||
|
.insert({
|
||||||
|
debt_id: record.id,
|
||||||
|
action: counterOfferContext
|
||||||
|
? "auto_counter_response_generated"
|
||||||
|
: "negotiation_generated",
|
||||||
|
details: {
|
||||||
|
strategy: emailResult.strategy,
|
||||||
|
amount: record.amount,
|
||||||
|
projected_savings: emailResult.projectedSavings,
|
||||||
|
ai_confidence: emailResult.confidenceLevel,
|
||||||
|
reasoning: emailResult.reasoning,
|
||||||
|
isCounterResponse: !!counterOfferContext,
|
||||||
|
counterOfferContext: counterOfferContext || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
strategy: emailResult.strategy,
|
||||||
|
projected_savings: emailResult.projectedSavings,
|
||||||
|
confidence: emailResult.confidenceLevel,
|
||||||
|
reasoning: emailResult.reasoning,
|
||||||
|
subject: emailResult.subject,
|
||||||
|
body: emailResult.body,
|
||||||
|
}),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,11 +8,6 @@
|
|||||||
- Ensures FDCPA compliance
|
- 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 = {
|
const corsHeaders = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Headers":
|
"Access-Control-Allow-Headers":
|
||||||
@@ -20,605 +15,21 @@ const corsHeaders = {
|
|||||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
"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) => {
|
Deno.serve(async (req) => {
|
||||||
// Handle CORS preflight requests
|
// Handle CORS preflight requests
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
return new Response("ok", { headers: corsHeaders });
|
return new Response("ok", { headers: corsHeaders });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Project has been disabled
|
||||||
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(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
error: "Project Disabled",
|
||||||
strategy: emailResult.strategy,
|
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
|
||||||
projected_savings: emailResult.projectedSavings,
|
}),
|
||||||
confidence: emailResult.confidenceLevel,
|
{
|
||||||
reasoning: emailResult.reasoning,
|
status: 503,
|
||||||
subject: emailResult.subject,
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
body: emailResult.body,
|
}
|
||||||
}),
|
|
||||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
494
supabase/functions/send-email/index-original.ts
Normal file
494
supabase/functions/send-email/index-original.ts
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
/*
|
||||||
|
# Email Sending Edge Function
|
||||||
|
|
||||||
|
This function sends negotiated emails via Postmark:
|
||||||
|
- Validates user has server token configured
|
||||||
|
- Processes email variables and replaces placeholders
|
||||||
|
- Sends the approved negotiation email to the debt collector
|
||||||
|
- Updates debt status and logs the action
|
||||||
|
- Ensures FDCPA compliance
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createClient } from "npm:@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers":
|
||||||
|
"authorization, x-client-info, apikey, content-type",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SendEmailRequest {
|
||||||
|
debtId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
postmark_server_token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DebtRecord {
|
||||||
|
id: string;
|
||||||
|
vendor: string;
|
||||||
|
amount: number;
|
||||||
|
raw_email: string;
|
||||||
|
negotiated_plan?: string;
|
||||||
|
projected_savings?: number;
|
||||||
|
conversation_count?: number;
|
||||||
|
metadata?: {
|
||||||
|
aiEmail?: {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
strategy: string;
|
||||||
|
confidence?: number;
|
||||||
|
};
|
||||||
|
toEmail?: string;
|
||||||
|
fromEmail?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send email using Postmark
|
||||||
|
async function sendEmailViaPostmark(
|
||||||
|
serverToken: string,
|
||||||
|
fromEmail: string,
|
||||||
|
toEmail: string,
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
) {
|
||||||
|
const postmarkEndpoint = "https://api.postmarkapp.com/email";
|
||||||
|
|
||||||
|
const emailData = {
|
||||||
|
From: fromEmail,
|
||||||
|
To: toEmail,
|
||||||
|
Subject: subject,
|
||||||
|
TextBody: body,
|
||||||
|
MessageStream: "outbound",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(postmarkEndpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Postmark-Server-Token": serverToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(emailData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.text();
|
||||||
|
throw new Error(`Postmark API error: ${response.status} - ${errorData}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract variables from text in {{ variable }} format
|
||||||
|
function extractVariables(text: string): string[] {
|
||||||
|
const variableRegex = /\{\{([^}]+)\}\}/g;
|
||||||
|
const variables: string[] = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = variableRegex.exec(text)) !== null) {
|
||||||
|
variables.push(match[1].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(variables)]; // Remove duplicates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace variables in text with their values
|
||||||
|
function replaceVariables(
|
||||||
|
text: string,
|
||||||
|
variables: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
return text.replace(/\{\{([^}]+)\}\}/g, (match, variableName) => {
|
||||||
|
const trimmedName = variableName.trim();
|
||||||
|
return variables[trimmedName] || ""; // Replace with empty string if variable not found
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load variables from database for a specific debt
|
||||||
|
async function loadVariablesFromDatabase(
|
||||||
|
supabaseClient: any,
|
||||||
|
debtId: string,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
try {
|
||||||
|
const { data: dbVariables, error } = await supabaseClient
|
||||||
|
.from("debt_variables")
|
||||||
|
.select("variable_name, variable_value")
|
||||||
|
.eq("debt_id", debtId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const loadedVariables: Record<string, string> = {};
|
||||||
|
dbVariables?.forEach((dbVar: any) => {
|
||||||
|
loadedVariables[dbVar.variable_name] = dbVar.variable_value || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
return loadedVariables;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading variables:", error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process email template by replacing variables with their values
|
||||||
|
async function processEmailTemplate(
|
||||||
|
supabaseClient: any,
|
||||||
|
debtId: string,
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
): Promise<
|
||||||
|
{
|
||||||
|
processedSubject: string;
|
||||||
|
processedBody: string;
|
||||||
|
hasUnfilledVariables: boolean;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
// Extract all variables from subject and body
|
||||||
|
const allText = `${subject} ${body}`;
|
||||||
|
const extractedVars = extractVariables(allText);
|
||||||
|
|
||||||
|
// Load saved variables from database
|
||||||
|
const savedVariables = await loadVariablesFromDatabase(
|
||||||
|
supabaseClient,
|
||||||
|
debtId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if all variables have values
|
||||||
|
const unfilledVariables = extractedVars.filter((variable) =>
|
||||||
|
!savedVariables[variable] || savedVariables[variable].trim() === ""
|
||||||
|
);
|
||||||
|
const hasUnfilledVariables = unfilledVariables.length > 0;
|
||||||
|
|
||||||
|
// Replace variables in subject and body
|
||||||
|
const processedSubject = replaceVariables(subject, savedVariables);
|
||||||
|
const processedBody = replaceVariables(body, savedVariables);
|
||||||
|
|
||||||
|
return {
|
||||||
|
processedSubject,
|
||||||
|
processedBody,
|
||||||
|
hasUnfilledVariables,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract email address from various formats
|
||||||
|
function extractEmailAddress(emailString: string): string {
|
||||||
|
// Handle formats like "Name <email@domain.com>" or just "email@domain.com"
|
||||||
|
const emailMatch = emailString.match(/<([^>]+)>/) ||
|
||||||
|
emailString.match(/([^\s<>]+@[^\s<>]+)/);
|
||||||
|
return emailMatch ? emailMatch[1] : emailString;
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
try {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response(null, { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Supabase client with auth context
|
||||||
|
const supabaseClient = createClient(
|
||||||
|
Deno.env.get("SUPABASE_URL") ?? "",
|
||||||
|
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
|
||||||
|
{
|
||||||
|
global: {
|
||||||
|
headers: { Authorization: req.headers.get("Authorization") ?? "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the authenticated user
|
||||||
|
const authHeader = req.headers.get("Authorization");
|
||||||
|
if (!authHeader) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Authorization header required" }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.replace("Bearer ", "");
|
||||||
|
const { data: userData } = await supabaseClient.auth.getUser(token);
|
||||||
|
const user = userData.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Unauthorized" }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { debtId }: SendEmailRequest = await req.json();
|
||||||
|
|
||||||
|
if (!debtId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Missing debtId" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user profile with server token - using authenticated client
|
||||||
|
const { data: userProfile, error: userError } = await supabaseClient
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("postmark_server_token")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (userError || !userProfile) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "User not found" }),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userProfileData = userProfile as UserProfile;
|
||||||
|
|
||||||
|
if (!userProfileData.postmark_server_token) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Postmark server token not configured",
|
||||||
|
requiresConfiguration: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch debt record - RLS will ensure user can only access their own debts
|
||||||
|
const { data: debtRecord, error: debtError } = await supabaseClient
|
||||||
|
.from("debts")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", debtId)
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (debtError || !debtRecord) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Debt record not found" }),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const debt = debtRecord as DebtRecord;
|
||||||
|
|
||||||
|
// Validate that negotiated plan exists
|
||||||
|
if (!debt.negotiated_plan || !debt.metadata?.aiEmail) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "No negotiated plan found. Please generate negotiation first.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract email details and process variables
|
||||||
|
const { subject: rawSubject, body: rawBody } = debt.metadata.aiEmail;
|
||||||
|
const fromEmail = debt.metadata?.toEmail || user.email;
|
||||||
|
|
||||||
|
if (!fromEmail) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "No valid sender email found" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process email template and replace variables
|
||||||
|
const { processedSubject, processedBody, hasUnfilledVariables } =
|
||||||
|
await processEmailTemplate(
|
||||||
|
supabaseClient,
|
||||||
|
debtId,
|
||||||
|
rawSubject,
|
||||||
|
rawBody,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if there are unfilled variables
|
||||||
|
if (hasUnfilledVariables) {
|
||||||
|
// console.warn("Email contains unfilled variables");
|
||||||
|
// return new Response(
|
||||||
|
// JSON.stringify({
|
||||||
|
// error: "Email contains unfilled variables",
|
||||||
|
// details:
|
||||||
|
// "Please fill in all required variables before sending the email.",
|
||||||
|
// }),
|
||||||
|
// {
|
||||||
|
// status: 400,
|
||||||
|
// headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = processedSubject;
|
||||||
|
const body = processedBody;
|
||||||
|
|
||||||
|
// Determine recipient email
|
||||||
|
let toEmail = debt.vendor;
|
||||||
|
if (debt.metadata?.fromEmail) {
|
||||||
|
toEmail = extractEmailAddress(debt.metadata.fromEmail);
|
||||||
|
} else if (debt.vendor.includes("@")) {
|
||||||
|
toEmail = extractEmailAddress(debt.vendor);
|
||||||
|
} else {
|
||||||
|
// If vendor doesn't contain email, try to construct one
|
||||||
|
toEmail = `collections@${
|
||||||
|
debt.vendor.toLowerCase().replace(/\s+/g, "")
|
||||||
|
}.com`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send email via Postmark
|
||||||
|
const emailResult = await sendEmailViaPostmark(
|
||||||
|
userProfileData.postmark_server_token,
|
||||||
|
fromEmail,
|
||||||
|
toEmail,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update debt status to sent and preserve negotiating state
|
||||||
|
const { data: currentDebt } = await supabaseClient
|
||||||
|
.from("debts")
|
||||||
|
.select("conversation_count, negotiation_round")
|
||||||
|
.eq("id", debtId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const { error: updateError } = await supabaseClient
|
||||||
|
.from("debts")
|
||||||
|
.update({
|
||||||
|
status: "sent",
|
||||||
|
conversation_count: (currentDebt?.conversation_count || 0) + 1,
|
||||||
|
last_message_at: new Date().toISOString(),
|
||||||
|
prospected_savings: debt.projected_savings || 0, // Store prospected savings when sent
|
||||||
|
metadata: {
|
||||||
|
...debt.metadata,
|
||||||
|
emailSent: {
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
messageId: emailResult.MessageID,
|
||||||
|
to: toEmail,
|
||||||
|
from: fromEmail,
|
||||||
|
subject: subject,
|
||||||
|
},
|
||||||
|
prospectedSavings: {
|
||||||
|
amount: debt.projected_savings || 0,
|
||||||
|
percentage: debt.amount > 0
|
||||||
|
? ((debt.projected_savings || 0) / debt.amount * 100).toFixed(2)
|
||||||
|
: 0,
|
||||||
|
calculatedAt: new Date().toISOString(),
|
||||||
|
strategy: debt.metadata?.aiEmail?.strategy || "unknown",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.eq("id", debtId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error("Error updating debt status:", updateError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the sent email in conversation history with processed content
|
||||||
|
await supabaseClient.from("conversation_messages").insert({
|
||||||
|
debt_id: debtId,
|
||||||
|
message_type: "negotiation_sent",
|
||||||
|
direction: "outbound",
|
||||||
|
subject: processedSubject, // Use processed subject with variables replaced
|
||||||
|
body: processedBody, // Use processed body with variables replaced
|
||||||
|
from_email: fromEmail,
|
||||||
|
to_email: toEmail,
|
||||||
|
message_id: emailResult.MessageID,
|
||||||
|
ai_analysis: {
|
||||||
|
strategy: debt.metadata.aiEmail.strategy,
|
||||||
|
confidence: debt.metadata.aiEmail.confidence,
|
||||||
|
projectedSavings: debt.projected_savings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the email sending
|
||||||
|
await supabaseClient.from("audit_logs").insert({
|
||||||
|
debt_id: debtId,
|
||||||
|
action: "email_sent",
|
||||||
|
details: {
|
||||||
|
to: toEmail,
|
||||||
|
subject: subject,
|
||||||
|
postmarkMessageId: emailResult.MessageID,
|
||||||
|
conversationRound: currentDebt?.negotiation_round || 1,
|
||||||
|
strategy: debt.metadata.aiEmail.strategy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
messageId: emailResult.MessageID,
|
||||||
|
sentTo: toEmail,
|
||||||
|
sentFrom: fromEmail,
|
||||||
|
subject: subject,
|
||||||
|
}),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error("Email sending error:", emailError);
|
||||||
|
|
||||||
|
const errorMessage = emailError instanceof Error
|
||||||
|
? emailError.message
|
||||||
|
: String(emailError);
|
||||||
|
|
||||||
|
// Log the failed attempt - using authenticated client
|
||||||
|
await supabaseClient
|
||||||
|
.from("audit_logs")
|
||||||
|
.insert({
|
||||||
|
debt_id: debtId,
|
||||||
|
action: "email_send_failed",
|
||||||
|
details: {
|
||||||
|
error: errorMessage,
|
||||||
|
to: toEmail,
|
||||||
|
from: fromEmail,
|
||||||
|
subject: subject,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to send email",
|
||||||
|
details: errorMessage,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Send email function error:", error);
|
||||||
|
const errorMessage = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "An unknown error occurred" +
|
||||||
|
(Deno.env.get("NODE_ENV") === "development"
|
||||||
|
? `: ${JSON.stringify(error)}`
|
||||||
|
: "");
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: errorMessage }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -9,8 +9,6 @@
|
|||||||
- Ensures FDCPA compliance
|
- Ensures FDCPA compliance
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createClient } from "npm:@supabase/supabase-js@2";
|
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Headers":
|
"Access-Control-Allow-Headers":
|
||||||
@@ -18,477 +16,21 @@ const corsHeaders = {
|
|||||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
"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) => {
|
Deno.serve(async (req) => {
|
||||||
try {
|
// Handle CORS preflight requests
|
||||||
if (req.method === "OPTIONS") {
|
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) {
|
|
||||||
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" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// 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" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
170
supabase/functions/test-extraction/index-original.ts
Normal file
170
supabase/functions/test-extraction/index-original.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||||
|
import { generateObject } from "https://esm.sh/ai@3.4.7";
|
||||||
|
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
|
||||||
|
import { z } from "https://esm.sh/zod@3.22.4";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers":
|
||||||
|
"authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same schema as analyze-response
|
||||||
|
const responseAnalysisSchema = z.object({
|
||||||
|
intent: z.enum([
|
||||||
|
"acceptance",
|
||||||
|
"rejection",
|
||||||
|
"counter_offer",
|
||||||
|
"request_info",
|
||||||
|
"unclear",
|
||||||
|
]).describe("The primary intent of the response"),
|
||||||
|
sentiment: z.enum(["positive", "negative", "neutral"])
|
||||||
|
.describe("Overall sentiment of the response"),
|
||||||
|
confidence: z.number().min(0).max(1)
|
||||||
|
.describe("Confidence in the intent classification"),
|
||||||
|
extractedTerms: z.object({
|
||||||
|
proposedAmount: z.number().optional().describe(
|
||||||
|
"Any amount mentioned in response",
|
||||||
|
),
|
||||||
|
proposedPaymentPlan: z.string().optional().describe(
|
||||||
|
"Payment plan details if mentioned",
|
||||||
|
),
|
||||||
|
paymentTerms: z.object({
|
||||||
|
monthlyAmount: z.number().optional().describe("Monthly payment amount"),
|
||||||
|
numberOfPayments: z.number().optional().describe(
|
||||||
|
"Number of payments/installments",
|
||||||
|
),
|
||||||
|
totalAmount: z.number().optional().describe("Total amount to be paid"),
|
||||||
|
interestRate: z.number().optional().describe(
|
||||||
|
"Interest rate if applicable",
|
||||||
|
),
|
||||||
|
paymentFrequency: z.string().optional().describe(
|
||||||
|
"Payment frequency (monthly, weekly, etc.)",
|
||||||
|
),
|
||||||
|
}).optional().describe("Structured payment plan terms"),
|
||||||
|
deadline: z.string().optional().describe("Any deadline mentioned"),
|
||||||
|
conditions: z.array(z.string()).optional().describe(
|
||||||
|
"Any conditions or requirements mentioned",
|
||||||
|
),
|
||||||
|
}).describe("Key terms extracted from the response"),
|
||||||
|
reasoning: z.string().describe("Explanation of the analysis"),
|
||||||
|
suggestedNextAction: z.enum([
|
||||||
|
"accept_offer",
|
||||||
|
"send_counter",
|
||||||
|
"request_clarification",
|
||||||
|
"escalate_to_user",
|
||||||
|
"mark_settled",
|
||||||
|
]).describe("Recommended next action"),
|
||||||
|
requiresUserReview: z.boolean().describe(
|
||||||
|
"Whether this response needs human review",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
serve(async (req) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { testEmail } = await req.json();
|
||||||
|
|
||||||
|
if (!testEmail) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "testEmail is required" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
|
||||||
|
|
||||||
|
if (!googleApiKey) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Google API key not configured" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Testing extraction with email:", testEmail);
|
||||||
|
|
||||||
|
const result = await generateObject({
|
||||||
|
model: createGoogleGenerativeAI({
|
||||||
|
apiKey: googleApiKey,
|
||||||
|
})("gemini-2.5-flash-preview-04-17"),
|
||||||
|
system: `You are an expert financial analyst specializing in debt collection and negotiation responses.
|
||||||
|
Your job is to carefully analyze creditor responses and extract ALL financial terms mentioned.
|
||||||
|
|
||||||
|
CRITICAL: Always extract financial information when present. Look for:
|
||||||
|
|
||||||
|
AMOUNTS:
|
||||||
|
- Any dollar amounts mentioned ($1,000, $500, etc.)
|
||||||
|
- Settlement offers or counter-offers
|
||||||
|
- Monthly payment amounts
|
||||||
|
- Total payment amounts
|
||||||
|
|
||||||
|
PAYMENT PLANS:
|
||||||
|
- Monthly payment amounts (e.g., "$200 per month", "$150/month")
|
||||||
|
- Number of payments/installments (e.g., "12 months", "24 payments")
|
||||||
|
- Payment frequency (monthly, weekly, bi-weekly)
|
||||||
|
- Total amounts for payment plans
|
||||||
|
- Interest rates if mentioned
|
||||||
|
|
||||||
|
EXTRACT EVERYTHING: Even if amounts seem obvious, always include them in extractedTerms.`,
|
||||||
|
prompt: `Analyze this test email and extract ALL financial terms:
|
||||||
|
|
||||||
|
EMAIL: ${testEmail}
|
||||||
|
|
||||||
|
EXTRACTION REQUIREMENTS:
|
||||||
|
1. Find ANY dollar amounts mentioned in the email
|
||||||
|
2. Look for payment plan details (monthly amounts, number of payments)
|
||||||
|
3. Identify payment frequency (monthly, weekly, etc.)
|
||||||
|
4. Extract total amounts if mentioned
|
||||||
|
5. Note any interest rates or fees
|
||||||
|
6. Capture all conditions and requirements
|
||||||
|
|
||||||
|
EXAMPLES OF WHAT TO EXTRACT:
|
||||||
|
- "We can accept $250 per month" → monthlyAmount: 250
|
||||||
|
- "for 18 months" → numberOfPayments: 18
|
||||||
|
- "totaling $4,500" → totalAmount: 4500
|
||||||
|
- "settlement of $3,200" → proposedAmount: 3200
|
||||||
|
- "monthly payments" → paymentFrequency: "monthly"
|
||||||
|
|
||||||
|
Be thorough and extract ALL financial information present in the email.`,
|
||||||
|
schema: responseAnalysisSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("AI Analysis Result:", JSON.stringify(result.object, null, 2));
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
analysis: result.object,
|
||||||
|
extractedTerms: result.object.extractedTerms,
|
||||||
|
debug: {
|
||||||
|
emailLength: testEmail.length,
|
||||||
|
hasGoogleAPI: !!googleApiKey,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in test-extraction function:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Internal server error",
|
||||||
|
details: error.message
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,170 +1,25 @@
|
|||||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
|
||||||
import { generateObject } from "https://esm.sh/ai@3.4.7";
|
|
||||||
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
|
|
||||||
import { z } from "https://esm.sh/zod@3.22.4";
|
|
||||||
|
|
||||||
const corsHeaders = {
|
const corsHeaders = {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Headers":
|
"Access-Control-Allow-Headers":
|
||||||
"authorization, x-client-info, apikey, content-type",
|
"authorization, x-client-info, apikey, content-type",
|
||||||
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Same schema as analyze-response
|
Deno.serve(async (req) => {
|
||||||
const responseAnalysisSchema = z.object({
|
// Handle CORS preflight requests
|
||||||
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") {
|
if (req.method === "OPTIONS") {
|
||||||
return new Response("ok", { headers: corsHeaders });
|
return new Response("ok", { headers: corsHeaders });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Project has been disabled
|
||||||
const { testEmail } = await req.json();
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
if (!testEmail) {
|
error: "Project Disabled",
|
||||||
return new Response(
|
message: "The project has been disabled (it was part of a hackathon). To enable it, please contact me."
|
||||||
JSON.stringify({ error: "testEmail is required" }),
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 503,
|
||||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
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" },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user