From 4429f27e75822fecc3530a655be9aa4fd54cf9ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Sep 2025 19:16:52 +0000 Subject: [PATCH] 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> --- src/components/DebtTimeline.tsx | 1 - src/components/DisabledProject.tsx | 61 ++ src/lib/supabase.ts | 3 + src/pages/api/postmark.ts | 591 +----------- src/pages/configuration.astro | 4 +- src/pages/dashboard.astro | 4 +- src/pages/index.astro | 267 +----- .../analyze-response/index-original.ts | 854 +++++++++++++++++ supabase/functions/analyze-response/index.ts | 857 +----------------- .../functions/approve-debt/index-original.ts | 235 +++++ supabase/functions/approve-debt/index.ts | 222 +---- .../functions/negotiate/index-original.ts | 635 +++++++++++++ supabase/functions/negotiate/index.ts | 609 +------------ .../functions/send-email/index-original.ts | 494 ++++++++++ supabase/functions/send-email/index.ts | 490 +--------- .../test-extraction/index-original.ts | 170 ++++ supabase/functions/test-extraction/index.ts | 173 +--- 17 files changed, 2534 insertions(+), 3136 deletions(-) create mode 100644 src/components/DisabledProject.tsx create mode 100644 supabase/functions/analyze-response/index-original.ts create mode 100644 supabase/functions/approve-debt/index-original.ts create mode 100644 supabase/functions/negotiate/index-original.ts create mode 100644 supabase/functions/send-email/index-original.ts create mode 100644 supabase/functions/test-extraction/index-original.ts diff --git a/src/components/DebtTimeline.tsx b/src/components/DebtTimeline.tsx index f917424..04bf747 100644 --- a/src/components/DebtTimeline.tsx +++ b/src/components/DebtTimeline.tsx @@ -12,7 +12,6 @@ import { RefreshCw, } from "lucide-react"; import type { Debt } from "../lib/supabase"; -import { createClient } from "@supabase/supabase-js"; interface DebtTimelineProps { debt: Debt; diff --git a/src/components/DisabledProject.tsx b/src/components/DisabledProject.tsx new file mode 100644 index 0000000..75e0349 --- /dev/null +++ b/src/components/DisabledProject.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { AlertTriangle } from 'lucide-react'; + +interface DisabledProjectProps { + showAuth?: boolean; +} + +export function DisabledProject({ showAuth = false }: DisabledProjectProps) { + return ( +
+
+
+
+ +
+ +

+ Project Disabled +

+ +

+ The project has been disabled (it was part of a hackathon). To enable it, please contact me. +

+ + {showAuth && ( +
+
+

+ Authentication is still available: +

+ +
+
+ )} + +
+ + ← Back to Home + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 2d944f1..9e3a99a 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -1,6 +1,9 @@ // Type definitions for Appwrite migration // This file no longer contains Supabase client - use appwrite.ts instead +// Dummy export to prevent build errors for disabled components +export const supabase = null; + export type User = { id: string; email: string; diff --git a/src/pages/api/postmark.ts b/src/pages/api/postmark.ts index 6ae463d..93ff2e7 100644 --- a/src/pages/api/postmark.ts +++ b/src/pages/api/postmark.ts @@ -1,584 +1,15 @@ import type { APIRoute } from "astro"; -import { - createAppwriteAdmin, - getUserIdByEmail, - handleDatabaseError, -} from "../../lib/appwrite-admin"; -import { generateObject } from "ai"; -import { createGoogleGenerativeAI } from "@ai-sdk/google"; -import { z } from "zod"; -import { DATABASE_ID, COLLECTIONS } from "../../lib/appwrite"; -import { ID, Query } from "appwrite"; - -// Schema for debt information extraction -const debtSchema = z.object({ - amount: z.number().min(0).describe("The debt amount in dollars"), - vendor: z.string().describe("The name or identifier of the vendor/creditor"), - description: z.string().describe("Brief description of what the debt is for"), - dueDate: z.string().optional().describe("Due date if mentioned (ISO format)"), - isDebtCollection: z - .boolean() - .describe("Whether this appears to be a debt collection notice"), - successfullyParsed: z - .boolean() - .describe("Whether the debt information was successfully parsed"), -}); - -// Schema for opt-out detection -const optOutSchema = z.object({ - isOptOut: z.boolean().describe( - "Whether this email contains an opt-out request", - ), - confidence: z - .number() - .min(0) - .max(1) - .describe("Confidence level of the opt-out detection"), - reason: z - .string() - .describe("Explanation of why this was classified as opt-out or not"), -}); - -// Function to detect opt-out requests using AI -async function detectOptOutWithAI(emailText: string, fromEmail: string) { - try { - const googleApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY || - import.meta.env.GOOGLE_GENERATIVE_AI_API_KEY; - if (!googleApiKey) { - console.warn( - "Google API key not configured, falling back to keyword detection", - ); - return null; - } - - const result = await generateObject({ - model: createGoogleGenerativeAI({ - apiKey: googleApiKey, - })("gemini-2.5-flash-preview-04-17"), - system: - `You are an expert at analyzing email content to detect opt-out requests. - Analyze the email to determine if the sender is requesting to opt-out, unsubscribe, - or stop receiving communications. Consider: - - Explicit opt-out keywords (STOP, UNSUBSCRIBE, REMOVE, etc.) - - Implicit requests to stop communication - - Context and tone indicating unwillingness to continue correspondence - - Legal language requesting cessation of contact - Be conservative - only flag as opt-out if you're confident it's a genuine request.`, - prompt: `Analyze this email for opt-out intent: - - From: ${fromEmail} - Content: ${emailText}`, - schema: optOutSchema, - }); - - return result.object; - } catch (error) { - console.error("AI opt-out detection error:", error); - return null; - } -} - -// Function to parse debt information using AI -async function parseDebtWithAI(emailText: string, fromEmail: string) { - try { - // Check if Google API key is available - const googleApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY || - import.meta.env.GOOGLE_GENERATIVE_AI_API_KEY; - if (!googleApiKey) { - console.warn( - "Google API key not configured, falling back to regex parsing", - ); - throw new Error("No Google API key configured"); - } - - const result = await generateObject({ - model: createGoogleGenerativeAI({ - apiKey: googleApiKey, - })("gemini-2.5-flash-preview-04-17"), - system: - `You are an expert at analyzing debt collection and billing emails. - Extract key debt information from the email content. - Look for monetary amounts, creditor information, what the debt is for, and due dates. - If this doesn't appear to be a legitimate debt or billing notice, set amount to 0. - Be very accurate with amounts - look for dollar signs and numbers carefully.`, - prompt: `Parse this email for debt information: - - From: ${fromEmail} - Content: ${emailText}`, - schema: debtSchema, - }); - - return result.object; - } catch (error) { - console.error("AI parsing error:", error); - // Fallback to regex if AI fails - const amountMatch = emailText.match(/\$(\d+(?:,\d{3})*(?:\.\d{2})?)/); - return { - amount: amountMatch ? parseFloat(amountMatch[1].replace(/,/g, "")) : 0, - vendor: fromEmail || "unknown", - description: "Failed to parse with AI - using regex fallback", - isDebtCollection: amountMatch ? true : false, - successfullyParsed: false, - }; - } -} - -// Function to increment email processing usage -async function incrementEmailUsage( - userId: string, - appwriteAdmin: ReturnType, -) { - 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, -) { - 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, -) { - try { - const textBody = emailData.TextBody || emailData.HtmlBody || ""; - const fromEmail = emailData.FromFull?.Email || emailData.From || "unknown"; - const subject = emailData.Subject || ""; - const messageId = emailData.MessageID || `inbound-${Date.now()}`; - - // First, record this message in the conversation - await appwriteAdmin.databases.createDocument( - DATABASE_ID, - COLLECTIONS.CONVERSATION_MESSAGES, - ID.unique(), - { - debt_id: debt.$id, - message_type: "response_received", - direction: "inbound", - subject: subject, - body: textBody, - from_email: fromEmail, - to_email: emailData.ToFull?.[0]?.Email || emailData.To || "", - message_id: messageId, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - } - ); - - // Update debt conversation tracking - await appwriteAdmin.databases.updateDocument( - DATABASE_ID, - COLLECTIONS.DEBTS, - debt.$id, - { - conversation_count: debt.conversation_count + 1, - last_message_at: new Date().toISOString(), - status: "counter_negotiating", // Temporary status while analyzing - updated_at: new Date().toISOString() - } - ); - - // Call the analyze-response function - const appwriteEndpoint = process.env.PUBLIC_APPWRITE_ENDPOINT || - import.meta.env.PUBLIC_APPWRITE_ENDPOINT; - const appwriteProjectId = process.env.PUBLIC_APPWRITE_PROJECT_ID || - import.meta.env.PUBLIC_APPWRITE_PROJECT_ID; - const appwriteApiKey = process.env.APPWRITE_API_KEY || - import.meta.env.APPWRITE_API_KEY; - - if (appwriteEndpoint && appwriteProjectId && appwriteApiKey) { - const analyzeUrl = `${appwriteEndpoint}/functions/v1/analyze-response`; - - try { - const response = await fetch(analyzeUrl, { - method: "POST", - headers: { - "X-Appwrite-Project": appwriteProjectId, - "X-Appwrite-Key": appwriteApiKey, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - debtId: debt.$id, - fromEmail, - subject, - body: textBody, - messageId: messageId, - }), - }); - - if (response.ok) { - const result = await response.json(); - console.log("Response analysis completed:", result); - - return new Response( - JSON.stringify({ - success: true, - message: "Negotiation response processed", - analysis: result.analysis, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } else { - console.error( - "Error calling analyze-response function:", - await response.text(), - ); - } - } catch (analyzeError) { - console.error("Error calling analyze-response function:", analyzeError); - } - } - - // Fallback: just log the response and mark for manual review - await appwriteAdmin.databases.createDocument( - DATABASE_ID, - COLLECTIONS.AUDIT_LOGS, - ID.unique(), - { - debt_id: debt.$id, - action: "response_received_fallback", - details: { - fromEmail, - subject, - bodyPreview: textBody.substring(0, 200), - requiresManualReview: true, - }, - created_at: new Date().toISOString() - } - ); - - return new Response( - JSON.stringify({ success: true, message: "Response logged" }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } catch (error) { - console.error("Error handling negotiation response:", error); - return new Response( - JSON.stringify({ error: "Failed to process negotiation response" }), - { - status: 500, - headers: { "Content-Type": "application/json" }, - }, - ); - } -} export const POST: APIRoute = async ({ request }) => { - try { - // Create admin client for webhook operations - let appwriteAdmin; - try { - appwriteAdmin = createAppwriteAdmin(); - } catch (configError) { - console.error("Appwrite admin configuration error:", configError); - return new Response( - JSON.stringify({ error: "Server configuration error" }), - { - status: 500, - headers: { "Content-Type": "application/json" }, - }, - ); - } - - const data = await request.json(); - - // Validate essential webhook data - if (!data.TextBody && !data.HtmlBody) { - return new Response(JSON.stringify({ error: "No email content found" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - - // Check for opt-out keywords - const textBody = data.TextBody || data.HtmlBody || ""; - const fromEmail = data.FromFull?.Email || data.From || "unknown"; - const toEmail = data.ToFull?.[0]?.Email || data.To || ""; - - // Find the user who should receive this debt - const userId = await getUserIdByEmail(toEmail, appwriteAdmin); - if (!userId) { - console.warn(`No user found for email: ${toEmail}`); - return new Response("No matching user found", { status: 200 }); - } - - // Check if this is a response to an existing negotiation - const existingDebt = await checkForExistingNegotiation( - fromEmail, - toEmail, - appwriteAdmin, - ); - - console.log({ existingDebt, fromEmail, toEmail }); - if (existingDebt) { - console.log( - `Found existing negotiation for debt ${existingDebt.$id}, analyzing response...`, - ); - return await handleNegotiationResponse(existingDebt, data, appwriteAdmin); - } - - // Increment email processing usage - await incrementEmailUsage(userId, appwriteAdmin); - - // Check for opt-out using AI - const optOutDetection = await detectOptOutWithAI(textBody, fromEmail); - let hasOptOut = false; - - if (optOutDetection) { - hasOptOut = optOutDetection.isOptOut && optOutDetection.confidence > 0.7; - console.log( - `AI opt-out detection: ${hasOptOut} (confidence: ${optOutDetection.confidence})`, - ); - } else { - // Fallback to keyword matching if AI is unavailable - const optOutKeywords = ["STOP", "UNSUBSCRIBE", "OPT-OUT", "REMOVE"]; - hasOptOut = optOutKeywords.some((keyword) => - textBody.toUpperCase().includes(keyword) - ); - console.log("Using fallback keyword opt-out detection"); - } - - if (hasOptOut) { - // Log opt-out and don't process further - try { - await appwriteAdmin.databases.createDocument( - DATABASE_ID, - COLLECTIONS.DEBTS, - ID.unique(), - { - user_id: userId, - vendor: fromEmail, - amount: 0, - raw_email: textBody, - status: "opted_out", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - } - ); - } catch (error) { - console.error("Error logging opt-out:", error); - const errorInfo = handleDatabaseError(error); - return new Response(JSON.stringify({ error: errorInfo.message }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); - } - - return new Response("Opt-out processed", { status: 200 }); - } - - // Parse debt information using AI - const debtInfo = await parseDebtWithAI(textBody, fromEmail); - - if (!debtInfo || !debtInfo.successfullyParsed) { - console.warn("Failed to parse debt information"); - return new Response( - JSON.stringify({ error: "Failed to parse debt information" }), - { - status: 400, - headers: { "Content-Type": "application/json" }, - }, - ); - } - - // Insert debt record with AI-extracted information - let insertedDebt; - try { - insertedDebt = await appwriteAdmin.databases.createDocument( - DATABASE_ID, - COLLECTIONS.DEBTS, - ID.unique(), - { - user_id: userId, - vendor: debtInfo.vendor, - amount: debtInfo.amount, - raw_email: textBody, - status: "received", - description: debtInfo.description, - due_date: debtInfo.dueDate, - conversation_count: 1, - last_message_at: new Date().toISOString(), - negotiation_round: 1, - projected_savings: 0, - metadata: { - isDebtCollection: debtInfo.isDebtCollection, - subject: data.Subject, - fromEmail: fromEmail, - toEmail: toEmail, - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - } - ); - - // Record the initial debt email as the first conversation message - await appwriteAdmin.databases.createDocument( - DATABASE_ID, - COLLECTIONS.CONVERSATION_MESSAGES, - ID.unique(), - { - debt_id: insertedDebt.$id, - message_type: "initial_debt", - direction: "inbound", - subject: data.Subject, - body: textBody, - from_email: fromEmail, - to_email: toEmail, - message_id: data.MessageID || `initial-${Date.now()}`, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - } - ); - } catch (insertError) { - console.error("Error inserting debt:", insertError); - const errorInfo = handleDatabaseError(insertError); - - 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, + // 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: { "Content-Type": "application/json" }, - }); - } -}; + } + ); +}; \ No newline at end of file diff --git a/src/pages/configuration.astro b/src/pages/configuration.astro index b504f0b..a6ca5e3 100644 --- a/src/pages/configuration.astro +++ b/src/pages/configuration.astro @@ -1,7 +1,7 @@ --- import "@/styles/globals.css"; import Layout from "../layouts/Layout.astro"; -import { Configuration as ConfigComponent } from "../components/Configuration"; +import { DisabledProject } from "../components/DisabledProject"; import { Navbar } from "../components/Navbar"; import { AuthGuard } from "../components/AuthGuard"; import { Toaster } from "../components/ui/sonner"; @@ -11,7 +11,7 @@ import { Toaster } from "../components/ui/sonner"; - + diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro index 59e8a65..9449eae 100644 --- a/src/pages/dashboard.astro +++ b/src/pages/dashboard.astro @@ -1,7 +1,7 @@ --- import "@/styles/globals.css"; import Layout from "../layouts/Layout.astro"; -import { Dashboard } from "../components/Dashboard"; +import { DisabledProject } from "../components/DisabledProject"; import { AuthGuard } from "../components/AuthGuard"; import { Navbar } from "../components/Navbar"; import { Toaster } from "../components/ui/sonner"; @@ -11,7 +11,7 @@ import { Toaster } from "../components/ui/sonner"; - + diff --git a/src/pages/index.astro b/src/pages/index.astro index 3ea1af4..1727b85 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,274 +1,11 @@ --- import "@/styles/globals.css"; import Layout from "../layouts/Layout.astro"; +import { DisabledProject } from "../components/DisabledProject"; import { Navbar } from "../components/Navbar"; --- - -
- -
-
-
-

- AI-Powered - - Debt Resolution - -

-

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

- -
-
-
- - -
-
-
-

- How It Works -

-

- Simple, automated, and compliant debt resolution -

-
- -
-
-
- - - -
-

- Forward Emails -

-

- Simply forward your debt collection emails to our secure - processing address. -

-
- -
-
- - - -
-

- AI Analysis -

-

- Our AI analyzes the debt and generates FDCPA-compliant negotiation - strategies. -

-
- -
-
- - - -
-

- Track Progress -

-

- Monitor negotiations in real-time and track your potential - savings. -

-
-
-
-
- - -
-
-
-

- Why Choose InboxNegotiator? -

-
- -
-
-

- FDCPA Compliant -

-

- All responses follow Fair Debt Collection Practices Act - guidelines. -

-
- -
-

- Save Money -

-

- Potentially reduce your debt by up to 40% through strategic - negotiations. -

-
- -
-

- Real-time Updates -

-

- Track your negotiations with live dashboard updates. -

-
- -
-

- Secure & Private -

-

- Your financial information is encrypted and protected. -

-
-
-
-
- - -
-
-

- Ready to Take Control of Your Debt? -

-

- Join thousands who have successfully negotiated their debts with AI - assistance. -

- - Start Your Free Account - -
-
-
- - -
-
-
-
- - - - InboxNegotiator -
-

- AI-powered debt resolution platform -

-

- © 2025 InboxNegotiator. All rights reserved. -

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