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_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
|
||||
|
||||
# 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 { supabase } from "../../lib/supabase";
|
||||
import {
|
||||
createSupabaseAdmin,
|
||||
handleDatabaseError,
|
||||
} from "../../lib/supabase-admin";
|
||||
import { generateObject } from "ai";
|
||||
import {
|
||||
createGoogleGenerativeAI,
|
||||
@@ -17,13 +21,18 @@ const debtSchema = z.object({
|
||||
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;
|
||||
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"
|
||||
@@ -57,18 +66,42 @@ async function parseDebtWithAI(emailText: string, fromEmail: string) {
|
||||
vendor: fromEmail || "unknown",
|
||||
description: "Failed to parse with AI - using regex fallback",
|
||||
isDebtCollection: amountMatch ? true : false,
|
||||
successfullyParsed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 || "";
|
||||
const fromEmail = data.FromFull?.Email || "unknown";
|
||||
const textBody = data.TextBody || data.HtmlBody || "";
|
||||
const fromEmail = data.FromFull?.Email || data.From || "unknown";
|
||||
|
||||
const hasOptOut = optOutKeywords.some((keyword) =>
|
||||
textBody.toUpperCase().includes(keyword)
|
||||
@@ -76,7 +109,7 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
|
||||
if (hasOptOut) {
|
||||
// Log opt-out and don't process further
|
||||
const { error } = await supabase.from("debts").insert({
|
||||
const { error } = await supabaseAdmin.from("debts").insert({
|
||||
vendor: fromEmail,
|
||||
amount: 0,
|
||||
raw_email: textBody,
|
||||
@@ -85,7 +118,8 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
|
||||
if (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,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -97,8 +131,19 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
// 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 supabase
|
||||
const { data: insertedDebt, error: insertError } = await supabaseAdmin
|
||||
.from("debts")
|
||||
.insert({
|
||||
vendor: debtInfo.vendor,
|
||||
@@ -118,14 +163,22 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
|
||||
if (insertError) {
|
||||
console.error("Error inserting debt:", insertError);
|
||||
return new Response(JSON.stringify({ error: insertError.message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
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 supabase.from("audit_logs").insert({
|
||||
await supabaseAdmin.from("audit_logs").insert({
|
||||
debt_id: insertedDebt.id,
|
||||
action: "email_received",
|
||||
details: {
|
||||
@@ -139,8 +192,11 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
// 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;
|
||||
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY;
|
||||
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`;
|
||||
|
||||
Reference in New Issue
Block a user