mirror of
https://github.com/FranP-code/inbox-negotiator.git
synced 2025-10-13 00:42:26 +00:00
Enhance environment setup and add Supabase admin client for webhook operations
This commit is contained in:
@@ -3,8 +3,11 @@
|
|||||||
# Supabase Configuration
|
# Supabase Configuration
|
||||||
SUPABASE_URL=your_supabase_url_here
|
SUPABASE_URL=your_supabase_url_here
|
||||||
SUPABASE_ANON_KEY=your_supabase_anon_key_here
|
SUPABASE_ANON_KEY=your_supabase_anon_key_here
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here
|
||||||
|
|
||||||
# Google Generative AI API Key for Gemini model
|
# Google Generative AI API Key for Gemini model
|
||||||
GOOGLE_GENERATIVE_AI_API_KEY=your_google_api_key_here
|
GOOGLE_GENERATIVE_AI_API_KEY=your_google_api_key_here
|
||||||
|
|
||||||
# Add these same variables to your actual .env file
|
# Add these same variables to your actual .env file
|
||||||
|
# Note: The SUPABASE_SERVICE_ROLE_KEY is required for webhook operations
|
||||||
|
# to bypass Row Level Security (RLS) policies in server-side contexts
|
||||||
|
|||||||
60
README.md
60
README.md
@@ -1 +1,59 @@
|
|||||||
inbox-negotiator
|
# Inbox Negotiator
|
||||||
|
|
||||||
|
An AI-powered system that automatically negotiates debt collections and billing disputes through email processing.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **AI Email Processing**: Automatically parses incoming emails to extract debt information using Google's Gemini AI
|
||||||
|
- **Automated Negotiation**: Triggers negotiation workflows for legitimate debt collection notices
|
||||||
|
- **Webhook Integration**: Seamlessly processes emails through Postmark webhook integration
|
||||||
|
- **Row Level Security**: Secure database operations with proper authentication handling
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and configure the following variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Supabase Configuration
|
||||||
|
SUPABASE_URL=your_supabase_url_here
|
||||||
|
SUPABASE_ANON_KEY=your_supabase_anon_key_here
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here
|
||||||
|
|
||||||
|
# Google Generative AI API Key for Gemini model
|
||||||
|
GOOGLE_GENERATIVE_AI_API_KEY=your_google_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Environment Variables
|
||||||
|
|
||||||
|
- `SUPABASE_URL`: Your Supabase project URL
|
||||||
|
- `SUPABASE_ANON_KEY`: Supabase anonymous key for client-side operations
|
||||||
|
- `SUPABASE_SERVICE_ROLE_KEY`: Supabase service role key for server-side operations (bypasses RLS)
|
||||||
|
- `GOOGLE_GENERATIVE_AI_API_KEY`: Google API key for AI processing
|
||||||
|
|
||||||
|
## Webhook Configuration
|
||||||
|
|
||||||
|
The `/api/postmark` endpoint handles incoming email webhooks from Postmark. It:
|
||||||
|
|
||||||
|
1. Validates incoming email data
|
||||||
|
2. Processes opt-out requests
|
||||||
|
3. Uses AI to extract debt information
|
||||||
|
4. Stores processed data in Supabase
|
||||||
|
5. Triggers automated negotiation workflows
|
||||||
|
|
||||||
|
### RLS (Row Level Security) Handling
|
||||||
|
|
||||||
|
The webhook uses a service role client to bypass RLS policies, ensuring server-side operations can write to the database without user authentication. This is essential for webhook operations where no user session exists.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Ensure all environment variables are configured in your deployment environment, especially the `SUPABASE_SERVICE_ROLE_KEY` which is critical for webhook operations.
|
||||||
|
|||||||
51
src/lib/supabase-admin.ts
Normal file
51
src/lib/supabase-admin.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Supabase client with service role key for server-side operations
|
||||||
|
* This client bypasses Row Level Security (RLS) and should only be used in trusted contexts
|
||||||
|
* like webhooks, API routes, and server-side functions
|
||||||
|
*/
|
||||||
|
export function createSupabaseAdmin() {
|
||||||
|
const supabaseUrl =
|
||||||
|
process.env.PUBLIC_SUPABASE_URL || import.meta.env.PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseServiceKey =
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY ||
|
||||||
|
import.meta.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
console.log({ supabaseUrl, supabaseServiceKey });
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseServiceKey) {
|
||||||
|
throw new Error(
|
||||||
|
"Missing Supabase URL or Service Role Key for admin operations"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createClient(supabaseUrl, supabaseServiceKey, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle database errors with more user-friendly messages
|
||||||
|
*/
|
||||||
|
export function handleDatabaseError(error: any) {
|
||||||
|
let errorMessage = error.message;
|
||||||
|
|
||||||
|
if (error.message.includes("row-level security")) {
|
||||||
|
errorMessage = "Database access denied - please check RLS policies";
|
||||||
|
} else if (error.message.includes("duplicate key")) {
|
||||||
|
errorMessage = "Duplicate entry detected";
|
||||||
|
} else if (error.message.includes("foreign key")) {
|
||||||
|
errorMessage = "Invalid reference in data";
|
||||||
|
} else if (error.message.includes("not null")) {
|
||||||
|
errorMessage = "Required field is missing";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: errorMessage,
|
||||||
|
originalError: process.env.NODE_ENV === "development" ? error : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { supabase } from "../../lib/supabase";
|
import { supabase } from "../../lib/supabase";
|
||||||
|
import {
|
||||||
|
createSupabaseAdmin,
|
||||||
|
handleDatabaseError,
|
||||||
|
} from "../../lib/supabase-admin";
|
||||||
import { generateObject } from "ai";
|
import { generateObject } from "ai";
|
||||||
import {
|
import {
|
||||||
createGoogleGenerativeAI,
|
createGoogleGenerativeAI,
|
||||||
@@ -17,13 +21,18 @@ const debtSchema = z.object({
|
|||||||
isDebtCollection: z
|
isDebtCollection: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.describe("Whether this appears to be a debt collection notice"),
|
.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
|
// Function to parse debt information using AI
|
||||||
async function parseDebtWithAI(emailText: string, fromEmail: string) {
|
async function parseDebtWithAI(emailText: string, fromEmail: string) {
|
||||||
try {
|
try {
|
||||||
// Check if Google API key is available
|
// Check if Google API key is available
|
||||||
const googleApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
const googleApiKey =
|
||||||
|
process.env.GOOGLE_GENERATIVE_AI_API_KEY ||
|
||||||
|
import.meta.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||||
if (!googleApiKey) {
|
if (!googleApiKey) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"Google API key not configured, falling back to regex parsing"
|
"Google API key not configured, falling back to regex parsing"
|
||||||
@@ -57,18 +66,42 @@ async function parseDebtWithAI(emailText: string, fromEmail: string) {
|
|||||||
vendor: fromEmail || "unknown",
|
vendor: fromEmail || "unknown",
|
||||||
description: "Failed to parse with AI - using regex fallback",
|
description: "Failed to parse with AI - using regex fallback",
|
||||||
isDebtCollection: amountMatch ? true : false,
|
isDebtCollection: amountMatch ? true : false,
|
||||||
|
successfullyParsed: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
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();
|
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
|
// Check for opt-out keywords
|
||||||
const optOutKeywords = ["STOP", "UNSUBSCRIBE", "OPT-OUT", "REMOVE"];
|
const optOutKeywords = ["STOP", "UNSUBSCRIBE", "OPT-OUT", "REMOVE"];
|
||||||
const textBody = data.TextBody || "";
|
const textBody = data.TextBody || data.HtmlBody || "";
|
||||||
const fromEmail = data.FromFull?.Email || "unknown";
|
const fromEmail = data.FromFull?.Email || data.From || "unknown";
|
||||||
|
|
||||||
const hasOptOut = optOutKeywords.some((keyword) =>
|
const hasOptOut = optOutKeywords.some((keyword) =>
|
||||||
textBody.toUpperCase().includes(keyword)
|
textBody.toUpperCase().includes(keyword)
|
||||||
@@ -76,7 +109,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
if (hasOptOut) {
|
if (hasOptOut) {
|
||||||
// Log opt-out and don't process further
|
// Log opt-out and don't process further
|
||||||
const { error } = await supabase.from("debts").insert({
|
const { error } = await supabaseAdmin.from("debts").insert({
|
||||||
vendor: fromEmail,
|
vendor: fromEmail,
|
||||||
amount: 0,
|
amount: 0,
|
||||||
raw_email: textBody,
|
raw_email: textBody,
|
||||||
@@ -85,7 +118,8 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Error logging opt-out:", error);
|
console.error("Error logging opt-out:", error);
|
||||||
return new Response(JSON.stringify({ error: error.message }), {
|
const errorInfo = handleDatabaseError(error);
|
||||||
|
return new Response(JSON.stringify({ error: errorInfo.message }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
@@ -97,8 +131,19 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
// Parse debt information using AI
|
// Parse debt information using AI
|
||||||
const debtInfo = await parseDebtWithAI(textBody, fromEmail);
|
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
|
// Insert debt record with AI-extracted information
|
||||||
const { data: insertedDebt, error: insertError } = await supabase
|
const { data: insertedDebt, error: insertError } = await supabaseAdmin
|
||||||
.from("debts")
|
.from("debts")
|
||||||
.insert({
|
.insert({
|
||||||
vendor: debtInfo.vendor,
|
vendor: debtInfo.vendor,
|
||||||
@@ -118,14 +163,22 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
if (insertError) {
|
if (insertError) {
|
||||||
console.error("Error inserting debt:", insertError);
|
console.error("Error inserting debt:", insertError);
|
||||||
return new Response(JSON.stringify({ error: insertError.message }), {
|
const errorInfo = handleDatabaseError(insertError);
|
||||||
status: 500,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
return new Response(
|
||||||
});
|
JSON.stringify({
|
||||||
|
error: errorInfo.message,
|
||||||
|
details: errorInfo.originalError,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the email receipt
|
// Log the email receipt
|
||||||
await supabase.from("audit_logs").insert({
|
await supabaseAdmin.from("audit_logs").insert({
|
||||||
debt_id: insertedDebt.id,
|
debt_id: insertedDebt.id,
|
||||||
action: "email_received",
|
action: "email_received",
|
||||||
details: {
|
details: {
|
||||||
@@ -139,8 +192,11 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
// Trigger negotiation function if this is a legitimate debt
|
// Trigger negotiation function if this is a legitimate debt
|
||||||
if (debtInfo.amount > 0 && debtInfo.isDebtCollection) {
|
if (debtInfo.amount > 0 && debtInfo.isDebtCollection) {
|
||||||
// Access environment variables through Astro runtime
|
// Access environment variables through Astro runtime
|
||||||
const supabaseUrl = process.env.SUPABASE_URL;
|
const supabaseUrl =
|
||||||
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY;
|
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) {
|
if (supabaseUrl && supabaseAnonKey) {
|
||||||
const negotiateUrl = `${supabaseUrl}/functions/v1/negotiate`;
|
const negotiateUrl = `${supabaseUrl}/functions/v1/negotiate`;
|
||||||
|
|||||||
Reference in New Issue
Block a user