mirror of
https://github.com/FranP-code/inbox-negotiator.git
synced 2025-10-13 00:42:26 +00:00
- Updated Navbar component to include a link to the configuration page. - Added a new Settings icon and link for user configuration. - Improved user session handling and UI updates based on authentication state. feat: implement OnboardingDialog for user setup - Created OnboardingDialog component to guide users through initial setup. - Added functionality to collect additional email addresses during onboarding. - Integrated toast notifications for error handling during email addition. feat: extend Supabase admin functions for user management - Added functions to retrieve user IDs and full user information by email. - Implemented error handling and logging for database operations. feat: update Supabase schema with new user features - Created new tables: user_profiles, additional_emails, and email_processing_usage. - Enabled Row Level Security (RLS) on new tables with appropriate policies. - Added triggers and functions for automatic user profile creation and email usage tracking. feat: create public users table for simplified access - Established a public.users table to mirror relevant auth.users data. - Implemented triggers to automatically populate public.users upon user creation. - Set up RLS policies to restrict access to user data. chore: add configuration files for Supabase local development - Included .gitignore and config.toml for local Supabase setup. - Configured email testing server and other development settings. feat: add configuration page for user settings - Created configuration.astro page to manage user settings. - Integrated AuthGuard to protect the configuration route.
263 lines
7.7 KiB
TypeScript
263 lines
7.7 KiB
TypeScript
import type { APIRoute } from "astro";
|
|
import {
|
|
createSupabaseAdmin,
|
|
handleDatabaseError,
|
|
getUserIdByEmail,
|
|
} from "../../lib/supabase-admin";
|
|
import { generateObject } from "ai";
|
|
import {
|
|
createGoogleGenerativeAI,
|
|
} from "@ai-sdk/google";
|
|
import { z } from "zod";
|
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
|
|
// 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"),
|
|
});
|
|
|
|
// 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, supabaseAdmin: SupabaseClient) {
|
|
try {
|
|
// Call the database function to increment usage
|
|
const { error } = await supabaseAdmin.rpc('increment_email_usage', {
|
|
target_user_id: userId
|
|
});
|
|
|
|
if (error) {
|
|
console.error("Error incrementing email usage:", error);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error calling increment_email_usage:", error);
|
|
}
|
|
}
|
|
|
|
export const POST: APIRoute = async ({ request }) => {
|
|
try {
|
|
// Create service role client for webhook operations (bypasses RLS)
|
|
let supabaseAdmin;
|
|
try {
|
|
supabaseAdmin = createSupabaseAdmin();
|
|
} catch (configError) {
|
|
console.error("Supabase 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 optOutKeywords = ["STOP", "UNSUBSCRIBE", "OPT-OUT", "REMOVE"];
|
|
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, supabaseAdmin);
|
|
if (!userId) {
|
|
console.warn(`No user found for email: ${toEmail}`);
|
|
return new Response("No matching user found", { status: 200 });
|
|
}
|
|
|
|
// Increment email processing usage
|
|
await incrementEmailUsage(userId, supabaseAdmin);
|
|
|
|
const hasOptOut = optOutKeywords.some((keyword) =>
|
|
textBody.toUpperCase().includes(keyword)
|
|
);
|
|
|
|
if (hasOptOut) {
|
|
// Log opt-out and don't process further
|
|
const { error } = await supabaseAdmin.from("debts").insert({
|
|
user_id: userId,
|
|
vendor: fromEmail,
|
|
amount: 0,
|
|
raw_email: textBody,
|
|
status: "opted_out",
|
|
});
|
|
|
|
if (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
|
|
const { data: insertedDebt, error: insertError } = await supabaseAdmin
|
|
.from("debts")
|
|
.insert({
|
|
user_id: userId,
|
|
vendor: debtInfo.vendor,
|
|
amount: debtInfo.amount,
|
|
raw_email: textBody,
|
|
status: "received",
|
|
description: debtInfo.description,
|
|
due_date: debtInfo.dueDate,
|
|
metadata: {
|
|
isDebtCollection: debtInfo.isDebtCollection,
|
|
subject: data.Subject,
|
|
fromEmail: fromEmail,
|
|
toEmail: toEmail,
|
|
},
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
if (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 supabaseAdmin.from("audit_logs").insert({
|
|
debt_id: insertedDebt.id,
|
|
action: "email_received",
|
|
details: {
|
|
vendor: debtInfo.vendor,
|
|
amount: debtInfo.amount,
|
|
subject: data.Subject,
|
|
aiParsed: true,
|
|
},
|
|
});
|
|
|
|
// Trigger negotiation function if this is a legitimate debt
|
|
if (debtInfo.amount > 0 && debtInfo.isDebtCollection) {
|
|
// Access environment variables through Astro runtime
|
|
const supabaseUrl =
|
|
process.env.SUPABASE_URL || import.meta.env.PUBLIC_SUPABASE_URL;
|
|
const supabaseAnonKey =
|
|
process.env.SUPABASE_ANON_KEY ||
|
|
import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
|
|
|
|
if (supabaseUrl && supabaseAnonKey) {
|
|
const negotiateUrl = `${supabaseUrl}/functions/v1/negotiate`;
|
|
|
|
try {
|
|
await fetch(negotiateUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${supabaseAnonKey}`,
|
|
"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(
|
|
"Supabase environment variables not configured for negotiation trigger"
|
|
);
|
|
}
|
|
}
|
|
|
|
return new Response("OK", { status: 200 });
|
|
} catch (error) {
|
|
console.error("Postmark webhook error:", error);
|
|
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
|
status: 500,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
};
|