mirror of
https://github.com/FranP-code/inbox-negotiator.git
synced 2025-10-13 00:42:26 +00:00
Adds email approval & Postmark integration for negotiations
Enables users to approve and send negotiation emails directly via Postmark after configuring a server token in settings. Introduces new debt statuses ("approved", "sent"), UI for token management, and approval/rejection flows. Refactors notifications to use a modern toast library, adjusts dashboard status filters, and updates DB schema for new flows.
Empowers compliant, user-controlled negotiation and automated email delivery.
This commit is contained in:
13
.vscode/tasks.json
vendored
Normal file
13
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Dev Server",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "pnpm dev",
|
||||||
|
"group": "build",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -5,20 +5,20 @@ import {
|
|||||||
type UserProfile,
|
type UserProfile,
|
||||||
type EmailProcessingUsage,
|
type EmailProcessingUsage,
|
||||||
} from "../lib/supabase";
|
} from "../lib/supabase";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "./ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "./ui/label";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "./ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "./ui/progress";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "./ui/alert";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "./ui/separator";
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "../hooks/use-toast";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export function Configuration() {
|
export function Configuration() {
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
@@ -55,6 +55,10 @@ export function Configuration() {
|
|||||||
});
|
});
|
||||||
const [savingPersonalData, setSavingPersonalData] = useState(false);
|
const [savingPersonalData, setSavingPersonalData] = useState(false);
|
||||||
|
|
||||||
|
// Server token state
|
||||||
|
const [serverToken, setServerToken] = useState("");
|
||||||
|
const [savingServerToken, setSavingServerToken] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUserData();
|
fetchUserData();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -112,6 +116,11 @@ export function Configuration() {
|
|||||||
phone_number: userData.phone_number || "",
|
phone_number: userData.phone_number || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set server token
|
||||||
|
if (profileData) {
|
||||||
|
setServerToken(profileData.postmark_server_token || "");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching user data:", error);
|
console.error("Error fetching user data:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -142,21 +151,52 @@ export function Configuration() {
|
|||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast({
|
toast.success("Personal data updated", {
|
||||||
title: "Personal data updated",
|
|
||||||
description: "Your personal information has been saved successfully.",
|
description: "Your personal information has been saved successfully.",
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast({
|
toast.error("Error saving personal data", {
|
||||||
title: "Error saving personal data",
|
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: "destructive",
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSavingPersonalData(false);
|
setSavingPersonalData(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveServerToken = async () => {
|
||||||
|
setSavingServerToken(true);
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("user_profiles")
|
||||||
|
.update({
|
||||||
|
postmark_server_token: serverToken || null,
|
||||||
|
})
|
||||||
|
.eq("user_id", user.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Update local profile state
|
||||||
|
setProfile((prev) =>
|
||||||
|
prev ? { ...prev, postmark_server_token: serverToken || null } : null
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success("Server token updated", {
|
||||||
|
description: "Your Postmark server token has been saved successfully.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error("Error saving server token", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingServerToken(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addAdditionalEmail = async () => {
|
const addAdditionalEmail = async () => {
|
||||||
if (!newEmail || !profile) return;
|
if (!newEmail || !profile) return;
|
||||||
|
|
||||||
@@ -180,15 +220,12 @@ export function Configuration() {
|
|||||||
|
|
||||||
setAdditionalEmails([data, ...additionalEmails]);
|
setAdditionalEmails([data, ...additionalEmails]);
|
||||||
setNewEmail("");
|
setNewEmail("");
|
||||||
toast({
|
toast.success("Email added successfully", {
|
||||||
title: "Email added successfully",
|
|
||||||
description: "Additional email has been added to your account.",
|
description: "Additional email has been added to your account.",
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast({
|
toast.error("Error adding email", {
|
||||||
title: "Error adding email",
|
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: "destructive",
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setAddingEmail(false);
|
setAddingEmail(false);
|
||||||
@@ -207,15 +244,12 @@ export function Configuration() {
|
|||||||
setAdditionalEmails(
|
setAdditionalEmails(
|
||||||
additionalEmails.filter((email) => email.id !== emailId)
|
additionalEmails.filter((email) => email.id !== emailId)
|
||||||
);
|
);
|
||||||
toast({
|
toast.success("Email removed", {
|
||||||
title: "Email removed",
|
|
||||||
description: "Additional email has been removed from your account.",
|
description: "Additional email has been removed from your account.",
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast({
|
toast.error("Error removing email", {
|
||||||
title: "Error removing email",
|
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: "destructive",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -318,6 +352,55 @@ export function Configuration() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Email Settings */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
Email Settings
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure your Postmark server token to enable sending approved
|
||||||
|
negotiation emails
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server_token">Postmark Server Token</Label>
|
||||||
|
<Input
|
||||||
|
id="server_token"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your Postmark server token"
|
||||||
|
value={serverToken}
|
||||||
|
onChange={(e) => setServerToken(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Required to send approved negotiation emails. You can get this
|
||||||
|
from your{" "}
|
||||||
|
<a
|
||||||
|
href="https://account.postmarkapp.com/servers"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Postmark dashboard
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={saveServerToken}
|
||||||
|
disabled={savingServerToken}
|
||||||
|
className="min-w-[120px]"
|
||||||
|
>
|
||||||
|
{savingServerToken ? "Saving..." : "Save Token"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Personal Data */}
|
{/* Personal Data */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -169,7 +169,9 @@ export function Dashboard() {
|
|||||||
active: debts.filter((debt) =>
|
active: debts.filter((debt) =>
|
||||||
["received", "negotiating"].includes(debt.status)
|
["received", "negotiating"].includes(debt.status)
|
||||||
),
|
),
|
||||||
settled: debts.filter((debt) => debt.status === "settled"),
|
settled: debts.filter((debt) =>
|
||||||
|
["settled", "approved", "sent"].includes(debt.status)
|
||||||
|
),
|
||||||
failed: debts.filter((debt) =>
|
failed: debts.filter((debt) =>
|
||||||
["failed", "opted_out"].includes(debt.status)
|
["failed", "opted_out"].includes(debt.status)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "./ui/dialog";
|
} from "./ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "./ui/alert-dialog";
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
@@ -26,9 +37,14 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Edit3,
|
Edit3,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle,
|
||||||
|
ExternalLink,
|
||||||
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { supabase, type Debt, type DebtVariable } from "../lib/supabase";
|
import { supabase, type Debt, type DebtVariable } from "../lib/supabase";
|
||||||
import { toast } from "../hooks/use-toast";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface DebtCardProps {
|
interface DebtCardProps {
|
||||||
debt: Debt;
|
debt: Debt;
|
||||||
@@ -40,6 +56,9 @@ const statusColors = {
|
|||||||
"bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800",
|
"bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800",
|
||||||
negotiating:
|
negotiating:
|
||||||
"bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-800",
|
"bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-800",
|
||||||
|
approved:
|
||||||
|
"bg-teal-100 text-teal-800 border-teal-200 dark:bg-teal-900/20 dark:text-teal-300 dark:border-teal-800",
|
||||||
|
sent: "bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-900/20 dark:text-purple-300 dark:border-purple-800",
|
||||||
settled:
|
settled:
|
||||||
"bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800",
|
"bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800",
|
||||||
failed:
|
failed:
|
||||||
@@ -51,12 +70,25 @@ const statusColors = {
|
|||||||
const statusLabels = {
|
const statusLabels = {
|
||||||
received: "Received",
|
received: "Received",
|
||||||
negotiating: "Negotiating",
|
negotiating: "Negotiating",
|
||||||
|
approved: "Approved",
|
||||||
|
sent: "Sent",
|
||||||
settled: "Settled",
|
settled: "Settled",
|
||||||
failed: "Failed",
|
failed: "Failed",
|
||||||
opted_out: "Opted Out",
|
opted_out: "Opted Out",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
||||||
|
const [isApproving, setIsApproving] = useState(false);
|
||||||
|
const [isRejecting, setIsRejecting] = useState(false);
|
||||||
|
const [userProfile, setUserProfile] = useState<any>(null);
|
||||||
|
const [hasServerToken, setHasServerToken] = useState(false);
|
||||||
|
|
||||||
|
const isReadOnly =
|
||||||
|
debt.status === "approved" ||
|
||||||
|
debt.status === "sent" ||
|
||||||
|
debt.status === "failed" ||
|
||||||
|
debt.status === "opted_out";
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
@@ -110,6 +142,8 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [variables, setVariables] = useState<Record<string, string>>({});
|
const [variables, setVariables] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Check if debt is in read-only state (approved or sent)
|
||||||
|
|
||||||
// Load variables from database
|
// Load variables from database
|
||||||
const loadVariables = async () => {
|
const loadVariables = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -284,10 +318,8 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Error saving debt metadata:", error);
|
console.error("Error saving debt metadata:", error);
|
||||||
toast({
|
toast.error("Error", {
|
||||||
title: "Error",
|
|
||||||
description: "Failed to save email changes. Please try again.",
|
description: "Failed to save email changes. Please try again.",
|
||||||
variant: "destructive",
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -295,8 +327,7 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
// Save variables to database
|
// Save variables to database
|
||||||
await saveVariables(variables);
|
await saveVariables(variables);
|
||||||
|
|
||||||
toast({
|
toast.success("Changes saved", {
|
||||||
title: "Changes saved",
|
|
||||||
description:
|
description:
|
||||||
"Your email and variables have been updated successfully.",
|
"Your email and variables have been updated successfully.",
|
||||||
});
|
});
|
||||||
@@ -309,10 +340,8 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving changes:", error);
|
console.error("Error saving changes:", error);
|
||||||
toast({
|
toast.error("Error", {
|
||||||
title: "Error",
|
|
||||||
description: "Failed to save changes. Please try again.",
|
description: "Failed to save changes. Please try again.",
|
||||||
variant: "destructive",
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@@ -325,15 +354,25 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="flex-1">
|
<Button variant="outline" size="sm" className="flex-1">
|
||||||
<Edit3 className="h-4 w-4 mr-2" />
|
{isReadOnly ? (
|
||||||
Edit Response
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<Edit3 className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{isReadOnly ? "See Response" : "Edit Response"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Negotiation Response</DialogTitle>
|
<DialogTitle>
|
||||||
|
{isReadOnly
|
||||||
|
? "View Negotiation Response"
|
||||||
|
: "Edit Negotiation Response"}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Customize your FDCPA-compliant response before sending
|
{isReadOnly
|
||||||
|
? "Review your FDCPA-compliant response"
|
||||||
|
: "Customize your FDCPA-compliant response before sending"}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -363,10 +402,12 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
id={`var-${variableName}`}
|
id={`var-${variableName}`}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
!isReadOnly &&
|
||||||
handleVariableChange(variableName, e.target.value)
|
handleVariableChange(variableName, e.target.value)
|
||||||
}
|
}
|
||||||
placeholder={`Enter ${variableName.toLowerCase()}`}
|
placeholder={`Enter ${variableName.toLowerCase()}`}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
readOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -383,9 +424,12 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
<Input
|
<Input
|
||||||
id="subject"
|
id="subject"
|
||||||
value={subject}
|
value={subject}
|
||||||
onChange={(e) => handleSubjectChange(e.target.value)}
|
onChange={(e) =>
|
||||||
|
!isReadOnly && handleSubjectChange(e.target.value)
|
||||||
|
}
|
||||||
placeholder="Enter email subject (use {{ Variable Name }} for placeholders)"
|
placeholder="Enter email subject (use {{ Variable Name }} for placeholders)"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
readOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{/* Preview of subject with variables replaced */}
|
{/* Preview of subject with variables replaced */}
|
||||||
{Object.keys(variables).length > 0 && (
|
{Object.keys(variables).length > 0 && (
|
||||||
@@ -401,9 +445,12 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
<Textarea
|
<Textarea
|
||||||
id="body"
|
id="body"
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => handleBodyChange(e.target.value)}
|
onChange={(e) =>
|
||||||
|
!isReadOnly && handleBodyChange(e.target.value)
|
||||||
|
}
|
||||||
placeholder="Enter email body (use {{ Variable Name }} for placeholders)"
|
placeholder="Enter email body (use {{ Variable Name }} for placeholders)"
|
||||||
className="w-full min-h-[300px] font-mono text-sm"
|
className="w-full min-h-[300px] font-mono text-sm"
|
||||||
|
readOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
{/* Preview of body with variables replaced */}
|
{/* Preview of body with variables replaced */}
|
||||||
{Object.keys(variables).length > 0 && (
|
{Object.keys(variables).length > 0 && (
|
||||||
@@ -417,20 +464,183 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex justify-end gap-2 border-t pt-4">
|
{!isReadOnly && (
|
||||||
<Button variant="outline" onClick={() => setIsEditing(false)}>
|
<div className="flex justify-end gap-2 border-t pt-4">
|
||||||
Cancel
|
<Button variant="outline" onClick={() => setIsEditing(false)}>
|
||||||
</Button>
|
Cancel
|
||||||
<Button onClick={handleSave} disabled={isSaving}>
|
</Button>
|
||||||
{isSaving ? "Saving..." : "Save Changes"}
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
</Button>
|
{isSaving ? "Saving..." : "Save Changes"}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if user has server token configured
|
||||||
|
useEffect(() => {
|
||||||
|
checkServerToken();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkServerToken = async () => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("postmark_server_token")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
setUserProfile(profile);
|
||||||
|
setHasServerToken(!!profile?.postmark_server_token);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking server token:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle approve action
|
||||||
|
const handleApprove = async (sendEmail = true) => {
|
||||||
|
if (!hasServerToken && sendEmail) {
|
||||||
|
toast.error("Server Token Required", {
|
||||||
|
description:
|
||||||
|
"Please configure your Postmark server token in settings first.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsApproving(true);
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) throw new Error("User not authenticated");
|
||||||
|
|
||||||
|
if (sendEmail) {
|
||||||
|
// Call the send-email function
|
||||||
|
const { data, error } = await supabase.functions.invoke("send-email", {
|
||||||
|
body: {
|
||||||
|
debtId: debt.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
if (data.requiresConfiguration) {
|
||||||
|
toast.error("Configuration Required", {
|
||||||
|
description:
|
||||||
|
"Please set up your Postmark server token in configuration.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Email Sent Successfully", {
|
||||||
|
description: `Negotiation email sent to ${data.sentTo}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Call the approve-debt function to handle approval without sending email
|
||||||
|
const { data, error } = await supabase.functions.invoke(
|
||||||
|
"approve-debt",
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
debtId: debt.id,
|
||||||
|
approvalNote: "Approved by user without sending email",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
toast.success("Debt Approved", {
|
||||||
|
description: `Negotiation for ${data.vendor} has been approved and saved.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the data
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error in approval process:", error);
|
||||||
|
const action = sendEmail ? "send email" : "approve debt";
|
||||||
|
toast.error(`Failed to ${action}`, {
|
||||||
|
description:
|
||||||
|
error.message || `An error occurred while trying to ${action}.`,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsApproving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle reject action
|
||||||
|
const handleReject = async () => {
|
||||||
|
setIsRejecting(true);
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("debts")
|
||||||
|
.update({
|
||||||
|
status: "opted_out",
|
||||||
|
metadata: {
|
||||||
|
...debt.metadata,
|
||||||
|
rejected: {
|
||||||
|
rejectedAt: new Date().toISOString(),
|
||||||
|
reason: "User rejected negotiation",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.eq("id", debt.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Log the action
|
||||||
|
await supabase.from("audit_logs").insert({
|
||||||
|
debt_id: debt.id,
|
||||||
|
action: "negotiation_rejected",
|
||||||
|
details: {
|
||||||
|
rejectedAt: new Date().toISOString(),
|
||||||
|
reason: "User rejected negotiation",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Negotiation Rejected", {
|
||||||
|
description: "The negotiation has been marked as rejected.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh the data
|
||||||
|
if (onUpdate) onUpdate();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error rejecting negotiation:", error);
|
||||||
|
toast.error("Failed to Reject", {
|
||||||
|
description:
|
||||||
|
error.message || "An error occurred while rejecting the negotiation.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRejecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if approve button should be enabled
|
||||||
|
const canApprove = (sendEmail = true) => {
|
||||||
|
if (sendEmail) {
|
||||||
|
return (
|
||||||
|
debt.metadata?.aiEmail &&
|
||||||
|
debt.status === "negotiating" &&
|
||||||
|
hasServerToken
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return debt.metadata?.aiEmail && debt.status === "negotiating";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if buttons should be shown
|
||||||
|
const showApproveRejectButtons = () => {
|
||||||
|
return debt.metadata?.aiEmail && debt.status === "negotiating";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full hover:shadow-lg transition-all duration-300 border-l-4 border-l-primary">
|
<Card className="w-full hover:shadow-lg transition-all duration-300 border-l-4 border-l-primary">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -470,31 +680,141 @@ export function DebtCard({ debt, onUpdate }: DebtCardProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="space-y-3">
|
||||||
<Dialog>
|
<div className="flex gap-2">
|
||||||
<DialogTrigger asChild>
|
<Dialog>
|
||||||
<Button variant="outline" size="sm" className="flex-1">
|
<DialogTrigger asChild>
|
||||||
<Mail className="h-4 w-4 mr-2" />
|
<Button variant="outline" size="sm" className="flex-1">
|
||||||
View Email
|
<Mail className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
View Email
|
||||||
</DialogTrigger>
|
</Button>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
</DialogTrigger>
|
||||||
<DialogHeader>
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
<DialogTitle>Original Email</DialogTitle>
|
<DialogHeader>
|
||||||
<DialogDescription>
|
<DialogTitle>Original Email</DialogTitle>
|
||||||
From: {debt.vendor} • {formatDate(debt.created_at)}
|
<DialogDescription>
|
||||||
</DialogDescription>
|
From: {debt.vendor} • {formatDate(debt.created_at)}
|
||||||
</DialogHeader>
|
</DialogDescription>
|
||||||
<div className="mt-4">
|
</DialogHeader>
|
||||||
<pre className="whitespace-pre-wrap text-sm bg-gray-50 dark:bg-gray-800 p-4 rounded-lg border dark:border-gray-700">
|
<div className="mt-4">
|
||||||
{debt.raw_email || "No email content available"}
|
<pre className="whitespace-pre-wrap text-sm bg-gray-50 dark:bg-gray-800 p-4 rounded-lg border dark:border-gray-700">
|
||||||
</pre>
|
{debt.raw_email || "No email content available"}
|
||||||
</div>
|
</pre>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{debt.metadata?.aiEmail && <EditableResponseDialog />}
|
{debt.metadata?.aiEmail && <EditableResponseDialog />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approve/Reject Buttons */}
|
||||||
|
{showApproveRejectButtons() && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{!hasServerToken && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||||
|
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="text-sm text-amber-700 dark:text-amber-300 flex-1">
|
||||||
|
Configure your Postmark server token to enable email
|
||||||
|
sending.
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => (window.location.href = "/configuration")}
|
||||||
|
className="text-amber-700 border-amber-300 hover:bg-amber-100"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3 mr-1" />
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
disabled={!canApprove(true) || isApproving}
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4 mr-2" />
|
||||||
|
{isApproving ? "Sending..." : "Approve & Send"}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Send Negotiation Email
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will send the approved negotiation email to{" "}
|
||||||
|
{debt.vendor}. Make sure you have reviewed and are
|
||||||
|
satisfied with the email content.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleApprove()}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Send Email
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => handleApprove(false)}
|
||||||
|
disabled={isApproving || isRejecting || !canApprove(false)}
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4 mr-2" />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reject Button - only show if debt is not approved or sent */}
|
||||||
|
{!isReadOnly && (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isRejecting}
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4 mr-2" />
|
||||||
|
{isRejecting ? "Rejecting..." : "Reject"}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Reject Negotiation</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will mark the negotiation as rejected and opt you out
|
||||||
|
of this debt collection process. This action cannot be
|
||||||
|
undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleReject}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { supabase } from "../lib/supabase";
|
import { supabase } from "../lib/supabase";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "./ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "./ui/label";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "./ui/dialog";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "./ui/alert";
|
||||||
import { Mail, Plus, CheckCircle, ArrowRight, UserCheck } from "lucide-react";
|
import { Mail, Plus, CheckCircle, ArrowRight, UserCheck } from "lucide-react";
|
||||||
import { toast } from "../hooks/use-toast";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface OnboardingDialogProps {
|
interface OnboardingDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -63,10 +63,8 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
|||||||
|
|
||||||
setStep("email");
|
setStep("email");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast({
|
toast.error("Error", {
|
||||||
title: "Error",
|
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: "destructive",
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -102,10 +100,8 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
|
|||||||
|
|
||||||
setStep("complete");
|
setStep("complete");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast({
|
toast.error("Error", {
|
||||||
title: "Error",
|
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: "destructive",
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ export type Debt = {
|
|||||||
vendor: string;
|
vendor: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
raw_email: string | null;
|
raw_email: string | null;
|
||||||
status: "received" | "negotiating" | "settled" | "failed" | "opted_out";
|
status:
|
||||||
|
| "received"
|
||||||
|
| "negotiating"
|
||||||
|
| "approved"
|
||||||
|
| "sent"
|
||||||
|
| "settled"
|
||||||
|
| "failed"
|
||||||
|
| "opted_out";
|
||||||
negotiated_plan: string | null;
|
negotiated_plan: string | null;
|
||||||
projected_savings: number;
|
projected_savings: number;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -47,6 +54,7 @@ export type UserProfile = {
|
|||||||
onboarding_completed: boolean;
|
onboarding_completed: boolean;
|
||||||
first_login_at: string | null;
|
first_login_at: string | null;
|
||||||
email_processing_limit: number;
|
email_processing_limit: number;
|
||||||
|
postmark_server_token: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdditionalEmail = {
|
export type AdditionalEmail = {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import {
|
import {
|
||||||
createSupabaseAdmin,
|
createSupabaseAdmin,
|
||||||
handleDatabaseError,
|
|
||||||
getUserIdByEmail,
|
getUserIdByEmail,
|
||||||
|
handleDatabaseError,
|
||||||
} from "../../lib/supabase-admin";
|
} from "../../lib/supabase-admin";
|
||||||
import { generateObject } from "ai";
|
import { generateObject } from "ai";
|
||||||
import {
|
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
||||||
createGoogleGenerativeAI,
|
|
||||||
} from "@ai-sdk/google";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
@@ -27,7 +25,9 @@ const debtSchema = z.object({
|
|||||||
|
|
||||||
// Schema for opt-out detection
|
// Schema for opt-out detection
|
||||||
const optOutSchema = z.object({
|
const optOutSchema = z.object({
|
||||||
isOptOut: z.boolean().describe("Whether this email contains an opt-out request"),
|
isOptOut: z.boolean().describe(
|
||||||
|
"Whether this email contains an opt-out request",
|
||||||
|
),
|
||||||
confidence: z
|
confidence: z
|
||||||
.number()
|
.number()
|
||||||
.min(0)
|
.min(0)
|
||||||
@@ -41,11 +41,12 @@ const optOutSchema = z.object({
|
|||||||
// Function to detect opt-out requests using AI
|
// Function to detect opt-out requests using AI
|
||||||
async function detectOptOutWithAI(emailText: string, fromEmail: string) {
|
async function detectOptOutWithAI(emailText: string, fromEmail: string) {
|
||||||
try {
|
try {
|
||||||
const googleApiKey =
|
const googleApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY ||
|
||||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY ||
|
|
||||||
import.meta.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
import.meta.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||||
if (!googleApiKey) {
|
if (!googleApiKey) {
|
||||||
console.warn("Google API key not configured, falling back to keyword detection");
|
console.warn(
|
||||||
|
"Google API key not configured, falling back to keyword detection",
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +54,8 @@ async function detectOptOutWithAI(emailText: string, fromEmail: string) {
|
|||||||
model: createGoogleGenerativeAI({
|
model: createGoogleGenerativeAI({
|
||||||
apiKey: googleApiKey,
|
apiKey: googleApiKey,
|
||||||
})("gemini-2.5-flash-preview-04-17"),
|
})("gemini-2.5-flash-preview-04-17"),
|
||||||
system: `You are an expert at analyzing email content to detect opt-out requests.
|
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,
|
Analyze the email to determine if the sender is requesting to opt-out, unsubscribe,
|
||||||
or stop receiving communications. Consider:
|
or stop receiving communications. Consider:
|
||||||
- Explicit opt-out keywords (STOP, UNSUBSCRIBE, REMOVE, etc.)
|
- Explicit opt-out keywords (STOP, UNSUBSCRIBE, REMOVE, etc.)
|
||||||
@@ -79,12 +81,11 @@ async function detectOptOutWithAI(emailText: string, fromEmail: string) {
|
|||||||
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 =
|
const googleApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY ||
|
||||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY ||
|
|
||||||
import.meta.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",
|
||||||
);
|
);
|
||||||
throw new Error("No Google API key configured");
|
throw new Error("No Google API key configured");
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,8 @@ async function parseDebtWithAI(emailText: string, fromEmail: string) {
|
|||||||
model: createGoogleGenerativeAI({
|
model: createGoogleGenerativeAI({
|
||||||
apiKey: googleApiKey,
|
apiKey: googleApiKey,
|
||||||
})("gemini-2.5-flash-preview-04-17"),
|
})("gemini-2.5-flash-preview-04-17"),
|
||||||
system: `You are an expert at analyzing debt collection and billing emails.
|
system:
|
||||||
|
`You are an expert at analyzing debt collection and billing emails.
|
||||||
Extract key debt information from the email content.
|
Extract key debt information from the email content.
|
||||||
Look for monetary amounts, creditor information, what the debt is for, and due dates.
|
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.
|
If this doesn't appear to be a legitimate debt or billing notice, set amount to 0.
|
||||||
@@ -120,13 +122,15 @@ async function parseDebtWithAI(emailText: string, fromEmail: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Function to increment email processing usage
|
// Function to increment email processing usage
|
||||||
async function incrementEmailUsage(userId: string, supabaseAdmin: SupabaseClient) {
|
async function incrementEmailUsage(
|
||||||
|
userId: string,
|
||||||
|
supabaseAdmin: SupabaseClient,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
// Call the database function to increment usage
|
// Call the database function to increment usage
|
||||||
const { error } = await supabaseAdmin.rpc('increment_email_usage', {
|
const { error } = await supabaseAdmin.rpc("increment_email_usage", {
|
||||||
target_user_id: userId
|
target_user_id: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -150,7 +154,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +189,9 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
if (optOutDetection) {
|
if (optOutDetection) {
|
||||||
hasOptOut = optOutDetection.isOptOut && optOutDetection.confidence > 0.7;
|
hasOptOut = optOutDetection.isOptOut && optOutDetection.confidence > 0.7;
|
||||||
console.log(`AI opt-out detection: ${hasOptOut} (confidence: ${optOutDetection.confidence})`);
|
console.log(
|
||||||
|
`AI opt-out detection: ${hasOptOut} (confidence: ${optOutDetection.confidence})`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to keyword matching if AI is unavailable
|
// Fallback to keyword matching if AI is unavailable
|
||||||
const optOutKeywords = ["STOP", "UNSUBSCRIBE", "OPT-OUT", "REMOVE"];
|
const optOutKeywords = ["STOP", "UNSUBSCRIBE", "OPT-OUT", "REMOVE"];
|
||||||
@@ -227,7 +233,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +270,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,20 +289,19 @@ 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 =
|
const supabaseUrl = process.env.SUPABASE_URL ||
|
||||||
process.env.SUPABASE_URL || import.meta.env.PUBLIC_SUPABASE_URL;
|
import.meta.env.PUBLIC_SUPABASE_URL;
|
||||||
const supabaseAnonKey =
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ||
|
||||||
process.env.SUPABASE_ANON_KEY ||
|
import.meta.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
|
|
||||||
if (supabaseUrl && supabaseAnonKey) {
|
if (supabaseUrl && supabaseServiceKey) {
|
||||||
const negotiateUrl = `${supabaseUrl}/functions/v1/negotiate`;
|
const negotiateUrl = `${supabaseUrl}/functions/v1/negotiate`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(negotiateUrl, {
|
await fetch(negotiateUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${supabaseAnonKey}`,
|
Authorization: `Bearer ${supabaseServiceKey}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ record: insertedDebt }),
|
body: JSON.stringify({ record: insertedDebt }),
|
||||||
@@ -307,7 +312,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn(
|
console.warn(
|
||||||
"Supabase environment variables not configured for negotiation trigger"
|
"Supabase environment variables not configured for negotiation trigger",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Layout from "../layouts/Layout.astro";
|
|||||||
import { Configuration as ConfigComponent } from "../components/Configuration";
|
import { Configuration as ConfigComponent } from "../components/Configuration";
|
||||||
import { Navbar } from "../components/Navbar";
|
import { Navbar } from "../components/Navbar";
|
||||||
import { AuthGuard } from "../components/AuthGuard";
|
import { AuthGuard } from "../components/AuthGuard";
|
||||||
|
import { Toaster } from "../components/ui/sonner";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Configuration - InboxNegotiator">
|
<Layout title="Configuration - InboxNegotiator">
|
||||||
@@ -12,4 +13,5 @@ import { AuthGuard } from "../components/AuthGuard";
|
|||||||
<AuthGuard requireAuth={true} client:load>
|
<AuthGuard requireAuth={true} client:load>
|
||||||
<ConfigComponent client:load />
|
<ConfigComponent client:load />
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
|
<Toaster client:load />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
---
|
---
|
||||||
import '@/styles/globals.css'
|
import "@/styles/globals.css";
|
||||||
import Layout from '../layouts/Layout.astro';
|
import Layout from "../layouts/Layout.astro";
|
||||||
import { Dashboard } from '../components/Dashboard';
|
import { Dashboard } from "../components/Dashboard";
|
||||||
import { AuthGuard } from '../components/AuthGuard';
|
import { AuthGuard } from "../components/AuthGuard";
|
||||||
import { Navbar } from '../components/Navbar';
|
import { Navbar } from "../components/Navbar";
|
||||||
|
import { Toaster } from "../components/ui/sonner";
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Dashboard - InboxNegotiator">
|
<Layout title="Dashboard - InboxNegotiator">
|
||||||
<Navbar client:load />
|
<Navbar client:load />
|
||||||
|
|
||||||
<AuthGuard requireAuth={true} client:load>
|
<AuthGuard requireAuth={true} client:load>
|
||||||
<Dashboard client:load />
|
<Dashboard client:load />
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
</Layout>
|
<Toaster client:load />
|
||||||
|
</Layout>
|
||||||
|
|||||||
235
supabase/functions/approve-debt/index.ts
Normal file
235
supabase/functions/approve-debt/index.ts
Normal file
@@ -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" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -222,29 +222,32 @@ I am writing to propose a payment arrangement to resolve this matter.`;
|
|||||||
let proposal = "";
|
let proposal = "";
|
||||||
|
|
||||||
switch (strategy) {
|
switch (strategy) {
|
||||||
case "extension":
|
case "extension": {
|
||||||
proposal =
|
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 }}.
|
` 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.`;
|
During this extension period, I request that no additional fees or interest be applied to maintain the current balance.`;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "installment":
|
case "installment": {
|
||||||
const monthlyPayment = (record.amount / 3).toFixed(2);
|
const monthlyPayment = (record.amount / 3).toFixed(2);
|
||||||
proposal = ` I am able to pay the full balance of $${
|
proposal = ` I am able to pay the full balance of $${
|
||||||
record.amount.toFixed(2)
|
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 }}.`;
|
} 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;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "settlement":
|
case "settlement": {
|
||||||
const settlementAmount = (record.amount * 0.6).toFixed(2);
|
const settlementAmount = (record.amount * 0.6).toFixed(2);
|
||||||
proposal =
|
proposal =
|
||||||
` I would like to propose a lump-sum settlement offer of $${settlementAmount} (60% of the current balance) to resolve this matter completely.
|
` 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.`;
|
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;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "dispute":
|
case "dispute": {
|
||||||
proposal =
|
proposal =
|
||||||
` I am formally disputing this debt and requesting validation under Section 809(b) of the Fair Debt Collection Practices Act.
|
` I am formally disputing this debt and requesting validation under Section 809(b) of the Fair Debt Collection Practices Act.
|
||||||
|
|
||||||
@@ -256,6 +259,7 @@ Please provide:
|
|||||||
|
|
||||||
Until proper validation is provided, I request that all collection activities cease.`;
|
Until proper validation is provided, I request that all collection activities cease.`;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const closingResponse = `
|
const closingResponse = `
|
||||||
@@ -273,88 +277,129 @@ ${personalData.full_name || "{{ Your Typed Name }}"}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
Deno.serve(async (req) => {
|
Deno.serve(async (req) => {
|
||||||
|
// Handle CORS preflight requests
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method !== "POST") {
|
||||||
return new Response(null, { headers: corsHeaders });
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Method not allowed" }),
|
||||||
|
{
|
||||||
|
status: 405,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { record }: { record: DebtRecord } = await req.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") || "",
|
||||||
|
);
|
||||||
|
|
||||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
let user: { id: string } | null = null;
|
||||||
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
let supabaseClient;
|
||||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
|
||||||
|
|
||||||
// Fetch user personal data
|
if (isServiceRoleCall) {
|
||||||
const { data: userData, error: userError } = await supabase
|
// This is a webhook/service call - use admin client
|
||||||
.from("users")
|
supabaseClient = createClient(
|
||||||
.select(
|
Deno.env.get("SUPABASE_URL") ?? "",
|
||||||
"full_name, address_line_1, address_line_2, city, state, zip_code, phone_number",
|
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
|
||||||
)
|
);
|
||||||
.eq("id", record.user_id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (userError) {
|
// For webhook calls, we'll get the userId from the request body along with the record
|
||||||
console.error("Error fetching user data:", userError);
|
const { record }: { record: DebtRecord } = await req.json();
|
||||||
}
|
|
||||||
|
|
||||||
const personalData: PersonalData = userData || {};
|
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" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Generate AI-powered negotiation email
|
user = { id: record.user_id };
|
||||||
const emailResult = await generateNegotiationEmail(record, personalData);
|
|
||||||
|
|
||||||
// Update debt record with AI-generated content
|
// Use the record as-is for webhook calls
|
||||||
const { error: updateError } = await supabase
|
const personalData = await fetchUserPersonalData(supabaseClient, user.id);
|
||||||
.from("debts")
|
return await processNegotiation(supabaseClient, record, personalData);
|
||||||
.update({
|
} else {
|
||||||
negotiated_plan:
|
// This is an authenticated user call
|
||||||
`Subject: ${emailResult.subject}\n\n${emailResult.body}`,
|
if (!authHeader) {
|
||||||
projected_savings: emailResult.projectedSavings,
|
return new Response(
|
||||||
status: "negotiating",
|
JSON.stringify({ error: "Authorization header required" }),
|
||||||
metadata: {
|
{
|
||||||
...record.metadata,
|
status: 401,
|
||||||
aiEmail: {
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
subject: emailResult.subject,
|
},
|
||||||
body: emailResult.body,
|
);
|
||||||
strategy: emailResult.strategy,
|
}
|
||||||
confidence: emailResult.confidenceLevel,
|
|
||||||
reasoning: emailResult.reasoning,
|
// Initialize Supabase client with auth context
|
||||||
customTerms: emailResult.customTerms,
|
supabaseClient = createClient(
|
||||||
|
Deno.env.get("SUPABASE_URL") ?? "",
|
||||||
|
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
|
||||||
|
{
|
||||||
|
global: {
|
||||||
|
headers: { Authorization: authHeader },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
);
|
||||||
.eq("id", record.id);
|
|
||||||
|
|
||||||
if (updateError) {
|
const token = authHeader.replace("Bearer ", "");
|
||||||
throw updateError;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the action
|
|
||||||
await supabase
|
|
||||||
.from("audit_logs")
|
|
||||||
.insert({
|
|
||||||
debt_id: record.id,
|
|
||||||
action: "negotiation_generated",
|
|
||||||
details: {
|
|
||||||
strategy: emailResult.strategy,
|
|
||||||
amount: record.amount,
|
|
||||||
projected_savings: emailResult.projectedSavings,
|
|
||||||
ai_confidence: emailResult.confidenceLevel,
|
|
||||||
reasoning: emailResult.reasoning,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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" } },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Negotiation function error:", error);
|
console.error("Negotiation function error:", error);
|
||||||
const errorMessage = error instanceof Error
|
const errorMessage = error instanceof Error
|
||||||
@@ -372,3 +417,107 @@ Deno.serve(async (req) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to fetch user personal data
|
||||||
|
async function fetchUserPersonalData(
|
||||||
|
supabaseClient: ReturnType<typeof createClient>,
|
||||||
|
userId: string,
|
||||||
|
): Promise<PersonalData>;
|
||||||
|
async function fetchUserPersonalData(
|
||||||
|
supabaseClient: unknown,
|
||||||
|
userId: string,
|
||||||
|
): Promise<PersonalData>;
|
||||||
|
async function fetchUserPersonalData(
|
||||||
|
supabaseClient: unknown,
|
||||||
|
userId: string,
|
||||||
|
): Promise<PersonalData> {
|
||||||
|
const client = supabaseClient as ReturnType<typeof createClient>;
|
||||||
|
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<typeof createClient>,
|
||||||
|
record: DebtRecord,
|
||||||
|
personalData: PersonalData,
|
||||||
|
): Promise<Response>;
|
||||||
|
async function processNegotiation(
|
||||||
|
supabaseClient: unknown,
|
||||||
|
record: DebtRecord,
|
||||||
|
personalData: PersonalData,
|
||||||
|
): Promise<Response>;
|
||||||
|
async function processNegotiation(
|
||||||
|
supabaseClient: unknown,
|
||||||
|
record: DebtRecord,
|
||||||
|
personalData: PersonalData,
|
||||||
|
): Promise<Response> {
|
||||||
|
const client = supabaseClient as ReturnType<typeof createClient>;
|
||||||
|
|
||||||
|
// Generate AI-powered negotiation email
|
||||||
|
const emailResult = await generateNegotiationEmail(record, personalData);
|
||||||
|
|
||||||
|
// 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: "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: "negotiation_generated",
|
||||||
|
details: {
|
||||||
|
strategy: emailResult.strategy,
|
||||||
|
amount: record.amount,
|
||||||
|
projected_savings: emailResult.projectedSavings,
|
||||||
|
ai_confidence: emailResult.confidenceLevel,
|
||||||
|
reasoning: emailResult.reasoning,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
341
supabase/functions/send-email/index.ts
Normal file
341
supabase/functions/send-email/index.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
/*
|
||||||
|
# Email Sending Edge Function
|
||||||
|
|
||||||
|
This function sends negotiated emails via Postmark:
|
||||||
|
- Validates user has server token configured
|
||||||
|
- 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;
|
||||||
|
metadata?: {
|
||||||
|
aiEmail?: {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
strategy: string;
|
||||||
|
};
|
||||||
|
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 email address from various formats
|
||||||
|
function extractEmailAddress(emailString: string): string {
|
||||||
|
// Handle formats like "Name <email@domain.com>" 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
|
||||||
|
const { subject, body } = 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" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 - using authenticated client
|
||||||
|
const { error: updateError } = await supabaseClient
|
||||||
|
.from("debts")
|
||||||
|
.update({
|
||||||
|
status: "sent",
|
||||||
|
metadata: {
|
||||||
|
...debt.metadata,
|
||||||
|
emailSent: {
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
messageId: emailResult.MessageID,
|
||||||
|
to: toEmail,
|
||||||
|
from: fromEmail,
|
||||||
|
subject: subject,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.eq("id", debtId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error("Error updating debt status:", updateError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the action - using authenticated client
|
||||||
|
await supabaseClient
|
||||||
|
.from("audit_logs")
|
||||||
|
.insert({
|
||||||
|
debt_id: debtId,
|
||||||
|
action: "email_sent",
|
||||||
|
details: {
|
||||||
|
messageId: emailResult.MessageID,
|
||||||
|
to: toEmail,
|
||||||
|
from: fromEmail,
|
||||||
|
subject: subject,
|
||||||
|
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" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
# Add Postmark Server Token
|
||||||
|
|
||||||
|
1. Changes
|
||||||
|
- Add postmark_server_token column to user_profiles table
|
||||||
|
- This will be used for sending approved emails
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Add postmark_server_token to user_profiles
|
||||||
|
ALTER TABLE user_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS postmark_server_token text;
|
||||||
|
|
||||||
|
-- Add comment for documentation
|
||||||
|
COMMENT ON COLUMN user_profiles.postmark_server_token IS 'Postmark server token for sending approved negotiation emails';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Add postmark_server_token to user_profiles table
|
||||||
|
ALTER TABLE user_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS postmark_server_token TEXT;
|
||||||
|
|
||||||
|
-- Update debts table status constraint to include 'sent'
|
||||||
|
ALTER TABLE debts
|
||||||
|
DROP CONSTRAINT IF EXISTS debts_status_check;
|
||||||
|
|
||||||
|
ALTER TABLE debts
|
||||||
|
ADD CONSTRAINT debts_status_check
|
||||||
|
CHECK (status IN ('received', 'negotiating', 'sent', 'settled', 'failed', 'opted_out'));
|
||||||
|
|
||||||
|
-- Create index on postmark_server_token for faster lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_profiles_postmark_token
|
||||||
|
ON user_profiles(user_id) WHERE postmark_server_token IS NOT NULL;
|
||||||
|
|
||||||
|
-- Add comment for documentation
|
||||||
|
COMMENT ON COLUMN user_profiles.postmark_server_token IS 'Postmark server token for sending negotiation emails';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Add 'approved' status to debts table
|
||||||
|
ALTER TABLE debts
|
||||||
|
DROP CONSTRAINT IF EXISTS debts_status_check;
|
||||||
|
|
||||||
|
ALTER TABLE debts
|
||||||
|
ADD CONSTRAINT debts_status_check
|
||||||
|
CHECK (status IN ('received', 'negotiating', 'approved', 'sent', 'settled', 'failed', 'opted_out'));
|
||||||
Reference in New Issue
Block a user