+ );
+}
\ 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.
-
-
-
-
-
-
+
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
From e8c6a06b3c2dfe1ba96ace3446019ac7dc39c1cf Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 1 Sep 2025 19:26:20 +0000
Subject: [PATCH 4/4] Implement user feedback: Show app with alert banner
instead of replacing pages
Co-authored-by: FranP-code <76450203+FranP-code@users.noreply.github.com>
---
src/components/DisabledBanner.tsx | 45 +++++
src/pages/configuration.astro | 6 +-
src/pages/dashboard.astro | 6 +-
src/pages/index.astro | 269 +++++++++++++++++++++++++++++-
4 files changed, 320 insertions(+), 6 deletions(-)
create mode 100644 src/components/DisabledBanner.tsx
diff --git a/src/components/DisabledBanner.tsx b/src/components/DisabledBanner.tsx
new file mode 100644
index 0000000..b80b74a
--- /dev/null
+++ b/src/components/DisabledBanner.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { AlertTriangle, X } from 'lucide-react';
+
+interface DisabledBannerProps {
+ onDismiss?: () => void;
+ dismissible?: boolean;
+}
+
+export function DisabledBanner({ onDismiss, dismissible = false }: DisabledBannerProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ Project Disabled
+
+
+ This project has been disabled (it was part of a hackathon). Functionality is limited. To enable it, please contact me.
+
+
+
+ {dismissible && onDismiss && (
+
+
+
+ )}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/configuration.astro b/src/pages/configuration.astro
index a6ca5e3..a48fd5f 100644
--- a/src/pages/configuration.astro
+++ b/src/pages/configuration.astro
@@ -1,17 +1,19 @@
---
import "@/styles/globals.css";
import Layout from "../layouts/Layout.astro";
-import { DisabledProject } from "../components/DisabledProject";
+import { Configuration as ConfigComponent } from "../components/Configuration";
import { Navbar } from "../components/Navbar";
import { AuthGuard } from "../components/AuthGuard";
+import { DisabledBanner } from "../components/DisabledBanner";
import { Toaster } from "../components/ui/sonner";
---
+
-
+
diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro
index 9449eae..7474efc 100644
--- a/src/pages/dashboard.astro
+++ b/src/pages/dashboard.astro
@@ -1,17 +1,19 @@
---
import "@/styles/globals.css";
import Layout from "../layouts/Layout.astro";
-import { DisabledProject } from "../components/DisabledProject";
+import { Dashboard } from "../components/Dashboard";
import { AuthGuard } from "../components/AuthGuard";
import { Navbar } from "../components/Navbar";
+import { DisabledBanner } from "../components/DisabledBanner";
import { Toaster } from "../components/ui/sonner";
---
+
-
+
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 1727b85..a799100 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,11 +1,276 @@
---
import "@/styles/globals.css";
import Layout from "../layouts/Layout.astro";
-import { DisabledProject } from "../components/DisabledProject";
import { Navbar } from "../components/Navbar";
+import { DisabledBanner } from "../components/DisabledBanner";
---
-
+
+
+
+
+
+
+
+
+ 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.
+