Enhance environment setup and add Supabase admin client for webhook operations

This commit is contained in:
2025-06-07 01:27:58 -03:00
parent 6ea4a9f3cf
commit 02ca59d34a
4 changed files with 182 additions and 14 deletions

View File

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

View File

@@ -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
View 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,
};
}

View File

@@ -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`;