mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Merge pull request #7 from renee-png/bills-approvals-fixes-2026-06-04
Bill approvals: surface approvers, fix email path, schema cleanup
This commit is contained in:
@@ -60,14 +60,18 @@ export default function BillApprovalRequestDialog({ open, onOpenChange, billId,
|
|||||||
try {
|
try {
|
||||||
await supabase.from('bills').update({ status: 'pending' }).eq('id', billId);
|
await supabase.from('bills').update({ status: 'pending' }).eq('id', billId);
|
||||||
|
|
||||||
|
const { data: userData } = await supabase.auth.getUser();
|
||||||
|
const createdBy = userData?.user?.id ?? null;
|
||||||
|
|
||||||
const approvalRows = selectedMembers.map((memberId) => {
|
const approvalRows = selectedMembers.map((memberId) => {
|
||||||
const bm = boardMembers.find(b => b.id === memberId);
|
const bm = boardMembers.find(b => b.id === memberId);
|
||||||
return {
|
return {
|
||||||
association_id: clientId,
|
association_id: clientId,
|
||||||
bill_id: billId,
|
bill_id: billId,
|
||||||
vendor_name: bm?.member_name || 'Approver',
|
approver_name: bm?.member_name || 'Approver',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
notes: comment || null,
|
notes: comment || null,
|
||||||
|
created_by: createdBy,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import { supabase } from "@/integrations/supabase/client";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
|
||||||
|
interface BillApprover {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface PendingBill {
|
interface PendingBill {
|
||||||
id: string;
|
id: string;
|
||||||
source_invoice_id?: string | null;
|
source_invoice_id?: string | null;
|
||||||
@@ -17,6 +22,7 @@ interface PendingBill {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
associations?: { name: string } | null;
|
associations?: { name: string } | null;
|
||||||
vendor_name?: string | null;
|
vendor_name?: string | null;
|
||||||
|
approvers: BillApprover[];
|
||||||
source: "bill" | "invoice";
|
source: "bill" | "invoice";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +47,7 @@ type PendingBillDbRow = Omit<PendingBill, "source" | "vendor_name"> & {
|
|||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
type BillApprovalStatusRow = { bill_id: string | null; status: string | null };
|
type BillApprovalStatusRow = { bill_id: string | null; status: string | null; approver_name: string | null };
|
||||||
|
|
||||||
const fmtMoney = (n: number | null) =>
|
const fmtMoney = (n: number | null) =>
|
||||||
typeof n === "number"
|
typeof n === "number"
|
||||||
@@ -83,9 +89,23 @@ const normalizePendingBill = (bill: PendingBillDbRow): PendingBill => ({
|
|||||||
created_at: bill.created_at,
|
created_at: bill.created_at,
|
||||||
associations: bill.associations,
|
associations: bill.associations,
|
||||||
vendor_name: bill.invoices?.vendor_name ?? bill.vendors?.name ?? bill.notes ?? null,
|
vendor_name: bill.invoices?.vendor_name ?? bill.vendors?.name ?? bill.notes ?? null,
|
||||||
|
approvers: [],
|
||||||
source: "bill",
|
source: "bill",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const approverBadgeClass = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "approved":
|
||||||
|
return "border-emerald-300 text-emerald-700 bg-emerald-50";
|
||||||
|
case "denied":
|
||||||
|
return "border-red-300 text-red-700 bg-red-50";
|
||||||
|
case "paid":
|
||||||
|
return "border-sky-300 text-sky-700 bg-sky-50";
|
||||||
|
default:
|
||||||
|
return "border-amber-300 text-amber-700 bg-amber-50";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export function BillApprovalsCard() {
|
export function BillApprovalsCard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [tab, setTab] = useState<"approvals" | "inbox">("approvals");
|
const [tab, setTab] = useState<"approvals" | "inbox">("approvals");
|
||||||
@@ -115,7 +135,7 @@ export function BillApprovalsCard() {
|
|||||||
// approved/denied). Those should not contribute to the badge.
|
// approved/denied). Those should not contribute to the badge.
|
||||||
supabase
|
supabase
|
||||||
.from("bill_approvals")
|
.from("bill_approvals")
|
||||||
.select("bill_id, status")
|
.select("bill_id, status, approver_name")
|
||||||
.not("bill_id", "is", null),
|
.not("bill_id", "is", null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -126,11 +146,20 @@ export function BillApprovalsCard() {
|
|||||||
// Group approvals by bill_id and figure out which pending bills are
|
// Group approvals by bill_id and figure out which pending bills are
|
||||||
// truly actionable (have ≥1 pending approval, or none at all).
|
// truly actionable (have ≥1 pending approval, or none at all).
|
||||||
const approvalsByBill = new Map<string, string[]>();
|
const approvalsByBill = new Map<string, string[]>();
|
||||||
|
const approversByBill = new Map<string, BillApprover[]>();
|
||||||
billApprovalStatusRows.forEach((a) => {
|
billApprovalStatusRows.forEach((a) => {
|
||||||
if (!a.bill_id) return;
|
if (!a.bill_id) return;
|
||||||
const arr = approvalsByBill.get(a.bill_id) || [];
|
const arr = approvalsByBill.get(a.bill_id) || [];
|
||||||
if (a.status) arr.push(a.status);
|
if (a.status) arr.push(a.status);
|
||||||
approvalsByBill.set(a.bill_id, arr);
|
approvalsByBill.set(a.bill_id, arr);
|
||||||
|
|
||||||
|
const approvers = approversByBill.get(a.bill_id) || [];
|
||||||
|
approvers.push({ name: a.approver_name || "Unknown", status: a.status || "pending" });
|
||||||
|
approversByBill.set(a.bill_id, approvers);
|
||||||
|
});
|
||||||
|
|
||||||
|
collected.forEach((bill) => {
|
||||||
|
bill.approvers = approversByBill.get(bill.id) || [];
|
||||||
});
|
});
|
||||||
const stuckBillIds: string[] = [];
|
const stuckBillIds: string[] = [];
|
||||||
let actionablePendingBills = 0;
|
let actionablePendingBills = 0;
|
||||||
@@ -312,6 +341,23 @@ export function BillApprovalsCard() {
|
|||||||
{formatDistanceToNow(new Date(bill.created_at), { addSuffix: true })}
|
{formatDistanceToNow(new Date(bill.created_at), { addSuffix: true })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{bill.approvers.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||||
|
{bill.approvers.map((ap, i) => (
|
||||||
|
<Badge
|
||||||
|
key={i}
|
||||||
|
variant="outline"
|
||||||
|
className={`text-2xs px-1.5 py-0 h-4 font-normal ${approverBadgeClass(ap.status)}`}
|
||||||
|
>
|
||||||
|
{ap.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-2xs italic text-muted-foreground mt-1">
|
||||||
|
No approvers requested
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function useBillApprovals() {
|
|||||||
approved_by: userData?.user?.id,
|
approved_by: userData?.user?.id,
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
notes: comment,
|
notes: comment,
|
||||||
vendor_name: voterName || 'Unknown',
|
approver_name: voterName || 'Unknown',
|
||||||
association_id: (bills.find(b => b.id === billId))?.association_id
|
association_id: (bills.find(b => b.id === billId))?.association_id
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
@@ -76,7 +76,7 @@ export function useBillApprovals() {
|
|||||||
approved_by: userData?.user?.id,
|
approved_by: userData?.user?.id,
|
||||||
status: 'denied',
|
status: 'denied',
|
||||||
notes: comment,
|
notes: comment,
|
||||||
vendor_name: voterName || 'Unknown',
|
approver_name: voterName || 'Unknown',
|
||||||
association_id: (bills.find(b => b.id === billId))?.association_id
|
association_id: (bills.find(b => b.id === billId))?.association_id
|
||||||
});
|
});
|
||||||
if (approvalError) throw approvalError;
|
if (approvalError) throw approvalError;
|
||||||
|
|||||||
@@ -1772,6 +1772,7 @@ export type Database = {
|
|||||||
amount: number
|
amount: number
|
||||||
approved_by: string | null
|
approved_by: string | null
|
||||||
approved_date: string | null
|
approved_date: string | null
|
||||||
|
approver_name: string
|
||||||
association_id: string
|
association_id: string
|
||||||
bill_id: string | null
|
bill_id: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -1781,12 +1782,12 @@ export type Database = {
|
|||||||
notes: string | null
|
notes: string | null
|
||||||
status: string
|
status: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
vendor_name: string
|
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
amount?: number
|
amount?: number
|
||||||
approved_by?: string | null
|
approved_by?: string | null
|
||||||
approved_date?: string | null
|
approved_date?: string | null
|
||||||
|
approver_name: string
|
||||||
association_id: string
|
association_id: string
|
||||||
bill_id?: string | null
|
bill_id?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
@@ -1796,12 +1797,12 @@ export type Database = {
|
|||||||
notes?: string | null
|
notes?: string | null
|
||||||
status?: string
|
status?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
vendor_name: string
|
|
||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
amount?: number
|
amount?: number
|
||||||
approved_by?: string | null
|
approved_by?: string | null
|
||||||
approved_date?: string | null
|
approved_date?: string | null
|
||||||
|
approver_name?: string
|
||||||
association_id?: string
|
association_id?: string
|
||||||
bill_id?: string | null
|
bill_id?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
@@ -1811,7 +1812,6 @@ export type Database = {
|
|||||||
notes?: string | null
|
notes?: string | null
|
||||||
status?: string
|
status?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
vendor_name?: string
|
|
||||||
}
|
}
|
||||||
Relationships: [
|
Relationships: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -370,11 +370,12 @@ export default function AIInvoiceParserPage() {
|
|||||||
return {
|
return {
|
||||||
association_id: reviewForm.association_id,
|
association_id: reviewForm.association_id,
|
||||||
bill_id: newBill?.id || null,
|
bill_id: newBill?.id || null,
|
||||||
vendor_name: bm?.member_name || reviewData.vendor_name,
|
approver_name: bm?.member_name || reviewData.vendor_name,
|
||||||
amount: reviewData.total_amount || 0,
|
amount: reviewData.total_amount || 0,
|
||||||
invoice_id: invoice.id,
|
invoice_id: invoice.id,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
notes: description || null,
|
notes: description || null,
|
||||||
|
created_by: userData?.user?.id || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
await supabase.from("bill_approvals").insert(approvalRows);
|
await supabase.from("bill_approvals").insert(approvalRows);
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
.from("bill_approvals")
|
.from("bill_approvals")
|
||||||
.select("bill_id")
|
.select("bill_id")
|
||||||
.in("association_id", boardAssociationIds!)
|
.in("association_id", boardAssociationIds!)
|
||||||
.in("vendor_name", names)
|
.in("approver_name", names)
|
||||||
.not("bill_id", "is", null);
|
.not("bill_id", "is", null);
|
||||||
assignedBillIds = Array.from(new Set((myApprovals || []).map((a: any) => a.bill_id).filter(Boolean)));
|
assignedBillIds = Array.from(new Set((myApprovals || []).map((a: any) => a.bill_id).filter(Boolean)));
|
||||||
}
|
}
|
||||||
@@ -217,7 +217,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
const billIds = billsList.map((b: any) => b.id);
|
const billIds = billsList.map((b: any) => b.id);
|
||||||
const { data: approvals } = await supabase
|
const { data: approvals } = await supabase
|
||||||
.from("bill_approvals")
|
.from("bill_approvals")
|
||||||
.select("id, bill_id, vendor_name, status")
|
.select("id, bill_id, approver_name, status")
|
||||||
.in("bill_id", billIds);
|
.in("bill_id", billIds);
|
||||||
const grouped: Record<string, any[]> = {};
|
const grouped: Record<string, any[]> = {};
|
||||||
(approvals || []).forEach((a: any) => {
|
(approvals || []).forEach((a: any) => {
|
||||||
@@ -455,7 +455,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
return {
|
return {
|
||||||
association_id: form.association_id,
|
association_id: form.association_id,
|
||||||
bill_id: newBill?.id || null,
|
bill_id: newBill?.id || null,
|
||||||
vendor_name: bm?.member_name || vendorDisplayName,
|
approver_name: bm?.member_name || vendorDisplayName,
|
||||||
amount: parseFloat(form.amount) || 0,
|
amount: parseFloat(form.amount) || 0,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
notes: form.description || null,
|
notes: form.description || null,
|
||||||
@@ -1084,7 +1084,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
<Badge variant="outline" className={`text-xs ${a.status === 'approved' ? 'border-emerald-300 text-emerald-700' : a.status === 'denied' || a.status === 'rejected' ? 'border-red-300 text-red-700' : 'border-amber-300 text-amber-700'}`}>
|
<Badge variant="outline" className={`text-xs ${a.status === 'approved' ? 'border-emerald-300 text-emerald-700' : a.status === 'denied' || a.status === 'rejected' ? 'border-red-300 text-red-700' : 'border-amber-300 text-amber-700'}`}>
|
||||||
{a.status === 'approved' ? '✓' : a.status === 'denied' || a.status === 'rejected' ? '✗' : '⏳'}
|
{a.status === 'approved' ? '✓' : a.status === 'denied' || a.status === 'rejected' ? '✗' : '⏳'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs truncate max-w-[120px]">{a.vendor_name}</span>
|
<span className="text-xs truncate max-w-[120px]">{a.approver_name}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -133,9 +133,14 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
|||||||
|
|
||||||
const handleApprovalAction = async (approvalId: string, action: "approved" | "denied") => {
|
const handleApprovalAction = async (approvalId: string, action: "approved" | "denied") => {
|
||||||
try {
|
try {
|
||||||
|
const { data: userData } = await supabase.auth.getUser();
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from("bill_approvals")
|
.from("bill_approvals")
|
||||||
.update({ status: action, approved_date: new Date().toISOString() })
|
.update({
|
||||||
|
status: action,
|
||||||
|
approved_date: new Date().toISOString(),
|
||||||
|
approved_by: userData?.user?.id ?? null,
|
||||||
|
})
|
||||||
.eq("id", approvalId);
|
.eq("id", approvalId);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
@@ -170,7 +175,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
|||||||
// Notify staff (admins/managers) that a board member has voted
|
// Notify staff (admins/managers) that a board member has voted
|
||||||
try {
|
try {
|
||||||
const approvalRecord = (bill?.bill_approvals || []).find((a: any) => a.id === approvalId);
|
const approvalRecord = (bill?.bill_approvals || []).find((a: any) => a.id === approvalId);
|
||||||
const voterName = approvalRecord?.vendor_name || "A board member";
|
const voterName = approvalRecord?.approver_name || "A board member";
|
||||||
const billLabel = bill?.invoice_number ? `Bill #${bill.invoice_number}` : `Bill ${(id || "").slice(0, 8)}`;
|
const billLabel = bill?.invoice_number ? `Bill #${bill.invoice_number}` : `Bill ${(id || "").slice(0, 8)}`;
|
||||||
const associationName = bill?.associations?.name || "an association";
|
const associationName = bill?.associations?.name || "an association";
|
||||||
const verb = action === "approved" ? "approved" : "denied";
|
const verb = action === "approved" ? "approved" : "denied";
|
||||||
@@ -445,56 +450,6 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Requested Approvers */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<Clock className="h-5 w-5 text-primary" /> Requested Approvers
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{approvals.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground italic">
|
|
||||||
No specific approvers requested during creation. You can request one above.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{approvals.map((a) => (
|
|
||||||
<div key={a.id} className="flex items-center justify-between border rounded-lg p-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium">{a.vendor_name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
${Number(a.amount).toFixed(2)} · {new Date(a.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
|
||||||
</p>
|
|
||||||
{a.notes && <p className="text-xs text-muted-foreground mt-1">{a.notes}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge className={statusColors[a.status] || "bg-muted text-muted-foreground"}>
|
|
||||||
{a.status.charAt(0).toUpperCase() + a.status.slice(1)}
|
|
||||||
</Badge>
|
|
||||||
{a.status === "pending" && (boardAssociationIds ? userBoardMemberNames.includes(a.vendor_name) : true) && (
|
|
||||||
<>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => handleApprovalAction(a.id, "approved")} className="h-7 px-2 text-xs gap-1">
|
|
||||||
<CheckCircle className="h-3 w-3" /> Approve
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => handleApprovalAction(a.id, "denied")} className="h-7 px-2 text-xs gap-1 text-destructive">
|
|
||||||
<XCircle className="h-3 w-3" /> Deny
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!boardAssociationIds && (
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => handleDeleteApproval(a.id)} className="h-7 px-2 text-xs text-destructive">
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{(lineItems.length > 0 || canEditLineItems) && (
|
{(lineItems.length > 0 || canEditLineItems) && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -674,17 +629,17 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Approval History */}
|
{/* Approvers */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<Clock className="h-5 w-5 text-primary" /> Approval History
|
<Clock className="h-5 w-5 text-primary" /> Approvers
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{approvals.length === 0 ? (
|
{approvals.length === 0 ? (
|
||||||
<div className="px-6 pb-6 text-sm text-muted-foreground italic">
|
<div className="px-6 pb-6 text-sm text-muted-foreground italic">
|
||||||
No approval records yet.
|
No approvers requested yet. Use the “Request Approval” button above to add one.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
@@ -702,7 +657,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
|||||||
<TableRow key={a.id}>
|
<TableRow key={a.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{a.vendor_name || "—"}</p>
|
<p className="text-sm font-medium">{a.approver_name || "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -724,7 +679,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{a.status === "pending" && (isBoardView ? userBoardMemberNames.includes(a.vendor_name) : true) && (
|
{a.status === "pending" && (isBoardView ? userBoardMemberNames.includes(a.approver_name) : true) && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -52,26 +52,36 @@ export default function MasterBoardDashboardPage() {
|
|||||||
.then(({ data }) => setAnnouncements(data || []));
|
.then(({ data }) => setAnnouncements(data || []));
|
||||||
}, [associationIds]);
|
}, [associationIds]);
|
||||||
|
|
||||||
// Pending bill approvals count for current user across assigned assocs
|
// Pending bill approvals count for current user across assigned assocs.
|
||||||
|
// bill_approvals doesn't store the approver's user_id directly — it stores
|
||||||
|
// the board member's display name (approver_name). We map auth.uid() to
|
||||||
|
// the board_member rows for the user, then match by member_name.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || !associationIds.length) { setPendingBillCount(0); return; }
|
if (!user || !associationIds.length) { setPendingBillCount(0); return; }
|
||||||
(async () => {
|
(async () => {
|
||||||
const billsQ: any = supabase
|
const [{ data: bills }, { data: myMemberships }] = await Promise.all([
|
||||||
.from("bills")
|
supabase
|
||||||
.select("id")
|
.from("bills")
|
||||||
.in("association_id", associationIds)
|
.select("id")
|
||||||
.in("status", ["pending", "pending_approval", "submitted"]);
|
.in("association_id", associationIds)
|
||||||
const { data: bills } = await billsQ;
|
.in("status", ["pending", "pending_approval", "submitted"]),
|
||||||
|
supabase
|
||||||
|
.from("board_members")
|
||||||
|
.select("member_name")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.in("association_id", associationIds),
|
||||||
|
]);
|
||||||
const billIds = (bills || []).map((b: any) => b.id);
|
const billIds = (bills || []).map((b: any) => b.id);
|
||||||
if (!billIds.length) { setPendingBillCount(0); return; }
|
const myNames = Array.from(
|
||||||
const client: any = supabase;
|
new Set((myMemberships || []).map((m: any) => m.member_name).filter(Boolean))
|
||||||
const q = client
|
);
|
||||||
|
if (!billIds.length || !myNames.length) { setPendingBillCount(0); return; }
|
||||||
|
const { count } = await supabase
|
||||||
.from("bill_approvals")
|
.from("bill_approvals")
|
||||||
.select("id", { count: "exact", head: true })
|
.select("id", { count: "exact", head: true })
|
||||||
.in("bill_id", billIds)
|
.in("bill_id", billIds)
|
||||||
.eq("approver_user_id", user.id)
|
.in("approver_name", myNames)
|
||||||
.eq("status", "pending");
|
.eq("status", "pending");
|
||||||
const { count } = await q;
|
|
||||||
setPendingBillCount(count ?? 0);
|
setPendingBillCount(count ?? 0);
|
||||||
})();
|
})();
|
||||||
}, [user, associationIds]);
|
}, [user, associationIds]);
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from 'npm:react@18.3.1'
|
||||||
|
import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from 'npm:@react-email/components@0.0.22'
|
||||||
|
import type { TemplateEntry } from './registry.ts'
|
||||||
|
|
||||||
|
const SITE_NAME = 'Avria Community Management'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
recipientName?: string
|
||||||
|
associationName?: string
|
||||||
|
link?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BillApprovalRequestEmail = ({ recipientName, associationName, link }: Props) => (
|
||||||
|
<Html lang="en" dir="ltr">
|
||||||
|
<Head />
|
||||||
|
<Preview>Bills awaiting your approval{associationName ? ` — ${associationName}` : ''}</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Section style={brandBar} />
|
||||||
|
<Heading style={h1}>Bills awaiting your approval</Heading>
|
||||||
|
<Text style={text}>Hello {recipientName || 'Board Member'},</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
One or more bills have been uploaded{associationName ? ` for ${associationName}` : ''} that
|
||||||
|
require your review and approval. You will receive a separate email for each individual bill
|
||||||
|
with secure approve / deny links, or you can review them together using the dashboard link below.
|
||||||
|
</Text>
|
||||||
|
{link && <Button href={link} style={button}>Review bills</Button>}
|
||||||
|
<Text style={smallText}>
|
||||||
|
You may also have received individual emails for each bill — those contain one-click approve and
|
||||||
|
deny buttons tied uniquely to your seat.
|
||||||
|
</Text>
|
||||||
|
<Text style={footer}>
|
||||||
|
If you weren't expecting this email, please contact your association manager. — {SITE_NAME}
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const template = {
|
||||||
|
component: BillApprovalRequestEmail,
|
||||||
|
subject: (d: Record<string, any>) =>
|
||||||
|
`Bills awaiting your approval${d.associationName ? ` — ${d.associationName}` : ''}`,
|
||||||
|
displayName: 'Bill approval request (summary)',
|
||||||
|
previewData: {
|
||||||
|
recipientName: 'Jane Director',
|
||||||
|
associationName: 'Sunset Hills HOA',
|
||||||
|
link: 'https://avria.cloud/dashboard/bill-approvals',
|
||||||
|
},
|
||||||
|
} satisfies TemplateEntry
|
||||||
|
|
||||||
|
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
|
||||||
|
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
|
||||||
|
const brandBar = { height: '5px', backgroundColor: '#2563eb', borderRadius: '6px', marginBottom: '24px' }
|
||||||
|
const h1 = { color: '#111827', fontSize: '24px', lineHeight: '32px', margin: '0 0 14px', fontWeight: '700' }
|
||||||
|
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 18px' }
|
||||||
|
const smallText = { color: '#6b7280', fontSize: '13px', lineHeight: '20px', margin: '14px 0 0' }
|
||||||
|
const button = { backgroundColor: '#2563eb', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px' }
|
||||||
|
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
import * as React from 'npm:react@18.3.1'
|
||||||
|
import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from 'npm:@react-email/components@0.0.22'
|
||||||
|
import type { TemplateEntry } from './registry.ts'
|
||||||
|
|
||||||
|
const SITE_NAME = 'Avria Community Management'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
memberName?: string
|
||||||
|
associationName?: string
|
||||||
|
vendorName?: string
|
||||||
|
invoiceNumber?: string
|
||||||
|
amount?: string
|
||||||
|
billDate?: string
|
||||||
|
dueDate?: string
|
||||||
|
description?: string
|
||||||
|
approveLink?: string
|
||||||
|
denyLink?: string
|
||||||
|
reviewLink?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BillApprovalVoteInviteEmail = ({
|
||||||
|
memberName, associationName, vendorName, invoiceNumber, amount, billDate, dueDate, description,
|
||||||
|
approveLink, denyLink, reviewLink,
|
||||||
|
}: Props) => (
|
||||||
|
<Html lang="en" dir="ltr">
|
||||||
|
<Head />
|
||||||
|
<Preview>Approve or deny: {vendorName || 'Bill'} {invoiceNumber || ''}</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Section style={brandBar} />
|
||||||
|
<Heading style={h1}>Bill approval requested</Heading>
|
||||||
|
<Text style={text}>Hello {memberName || 'Board Member'},</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
A bill{associationName ? ` for ${associationName}` : ''} requires your approval.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={panel}>
|
||||||
|
<Text style={label}>Vendor</Text>
|
||||||
|
<Text style={value}>{vendorName || '—'}</Text>
|
||||||
|
{invoiceNumber && (
|
||||||
|
<>
|
||||||
|
<Text style={label}>Invoice #</Text>
|
||||||
|
<Text style={value}>{invoiceNumber}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Text style={label}>Amount</Text>
|
||||||
|
<Text style={value}>{amount || '—'}</Text>
|
||||||
|
{(billDate || dueDate) && (
|
||||||
|
<>
|
||||||
|
<Text style={label}>{billDate ? 'Bill date' : ''}{billDate && dueDate ? ' · Due date' : (dueDate ? 'Due date' : '')}</Text>
|
||||||
|
<Text style={meta}>
|
||||||
|
{billDate || ''}{billDate && dueDate ? ' · ' : ''}{dueDate || ''}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<>
|
||||||
|
<Text style={label}>Description</Text>
|
||||||
|
<Text style={meta}>{description}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={buttonRow}>
|
||||||
|
{approveLink && <Button href={approveLink} style={approveButton}>Approve</Button>}
|
||||||
|
{denyLink && <Button href={denyLink} style={denyButton}>Deny</Button>}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{reviewLink && (
|
||||||
|
<Text style={smallText}>
|
||||||
|
Prefer to review the full bill first? <a href={reviewLink} style={link}>Open it here</a>.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text style={smallText}>
|
||||||
|
🔒 These approve / deny links are tied uniquely to your board seat. Please do not share or forward them.
|
||||||
|
</Text>
|
||||||
|
<Text style={footer}>
|
||||||
|
If you weren't expecting this email, please contact your association manager. — {SITE_NAME}
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const template = {
|
||||||
|
component: BillApprovalVoteInviteEmail,
|
||||||
|
subject: (d: Record<string, any>) =>
|
||||||
|
`Approve or deny: ${d.vendorName || 'Bill'}${d.invoiceNumber ? ` ${d.invoiceNumber}` : ''}`,
|
||||||
|
displayName: 'Bill approval vote invite',
|
||||||
|
previewData: {
|
||||||
|
memberName: 'John Director',
|
||||||
|
associationName: 'Sunset Hills HOA',
|
||||||
|
vendorName: 'Acme Plumbing',
|
||||||
|
invoiceNumber: 'INV-1042',
|
||||||
|
amount: '$1,250.00',
|
||||||
|
billDate: '2026-05-01',
|
||||||
|
dueDate: '2026-05-31',
|
||||||
|
description: 'Quarterly pump-station maintenance',
|
||||||
|
approveLink: 'https://avria.cloud/bill-approve/xxx?token=yyy&action=approve',
|
||||||
|
denyLink: 'https://avria.cloud/bill-approve/xxx?token=yyy&action=deny',
|
||||||
|
reviewLink: 'https://avria.cloud/bill-approve/xxx?token=yyy',
|
||||||
|
},
|
||||||
|
} satisfies TemplateEntry
|
||||||
|
|
||||||
|
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
|
||||||
|
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
|
||||||
|
const brandBar = { height: '5px', backgroundColor: '#2563eb', borderRadius: '6px', marginBottom: '24px' }
|
||||||
|
const h1 = { color: '#111827', fontSize: '24px', lineHeight: '32px', margin: '0 0 14px', fontWeight: '700' }
|
||||||
|
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 14px' }
|
||||||
|
const panel = { backgroundColor: '#f8fafc', border: '1px solid #e5e7eb', borderRadius: '8px', padding: '16px', margin: '14px 0 22px' }
|
||||||
|
const label = { color: '#6b7280', fontSize: '11px', textTransform: 'uppercase' as const, letterSpacing: '0.04em', margin: '8px 0 2px' }
|
||||||
|
const value = { color: '#111827', fontSize: '15px', lineHeight: '22px', fontWeight: '600', margin: '0' }
|
||||||
|
const meta = { color: '#374151', fontSize: '14px', lineHeight: '22px', margin: '0', whiteSpace: 'pre-wrap' as const }
|
||||||
|
const buttonRow = { margin: '0 0 18px' }
|
||||||
|
const approveButton = { backgroundColor: '#16a34a', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px', marginRight: '10px' }
|
||||||
|
const denyButton = { backgroundColor: '#dc2626', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px' }
|
||||||
|
const smallText = { color: '#6b7280', fontSize: '13px', lineHeight: '20px', margin: '0 0 10px' }
|
||||||
|
const link = { color: '#2563eb', textDecoration: 'underline' }
|
||||||
|
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from 'npm:react@18.3.1'
|
||||||
|
import { Body, Container, Head, Heading, Html, Preview, Text, Button } from 'npm:@react-email/components@0.0.22'
|
||||||
|
import type { TemplateEntry } from './registry.ts'
|
||||||
|
|
||||||
|
const SITE_NAME = 'Avria Community Management'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
memberName?: string
|
||||||
|
voteTitle?: string
|
||||||
|
voteDescription?: string
|
||||||
|
associationName?: string
|
||||||
|
link?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BoardVoteInviteEmail = ({ memberName, voteTitle, voteDescription, associationName, link }: Props) => (
|
||||||
|
<Html lang="en" dir="ltr">
|
||||||
|
<Head />
|
||||||
|
<Preview>Board vote — {voteTitle || 'Cast your vote'}</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Heading style={h1}>Board Vote: {voteTitle || 'New vote'}</Heading>
|
||||||
|
<Text style={text}>Hello {memberName || 'Board Member'},</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
A board vote has been opened{associationName ? ` for ${associationName}` : ''}:
|
||||||
|
</Text>
|
||||||
|
{voteDescription && <Text style={descBox}>{voteDescription}</Text>}
|
||||||
|
<Text style={text}>Please cast your vote using the secure link below.</Text>
|
||||||
|
<Button href={link || '#'} style={button}>Cast Your Vote</Button>
|
||||||
|
<Text style={smallText}><strong>🔒 This is a secure link</strong> tied uniquely to your board seat. Please do not share or forward it.</Text>
|
||||||
|
<Text style={smallText}><strong>For recording purposes only:</strong> This electronic vote will be formally ratified at a later board meeting.</Text>
|
||||||
|
<Text style={footer}>If you did not expect this email, please contact your association manager. — {SITE_NAME}</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const template = {
|
||||||
|
component: BoardVoteInviteEmail,
|
||||||
|
subject: (d: Record<string, any>) =>
|
||||||
|
`Board Vote — ${d.voteTitle || 'New vote'}${d.associationName ? ` (${d.associationName})` : ''}`,
|
||||||
|
displayName: 'Board vote invite',
|
||||||
|
previewData: { memberName: 'John Director', voteTitle: 'Approve 2026 Budget', voteDescription: 'Approve the proposed 2026 operating budget as presented.', associationName: 'Sample HOA', link: 'https://avria.cloud/board-vote/xxx?token=yyy' },
|
||||||
|
} satisfies TemplateEntry
|
||||||
|
|
||||||
|
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
|
||||||
|
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
|
||||||
|
const h1 = { color: '#111827', fontSize: '22px', lineHeight: '30px', margin: '0 0 14px', fontWeight: '700' }
|
||||||
|
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 12px' }
|
||||||
|
const descBox = { background: '#f6f7f9', border: '1px solid #e5e7eb', borderRadius: '6px', padding: '12px 14px', color: '#374151', fontSize: '14px', whiteSpace: 'pre-wrap' as const, margin: '0 0 16px' }
|
||||||
|
const smallText = { color: '#6b7280', fontSize: '13px', lineHeight: '20px', margin: '0 0 10px' }
|
||||||
|
const button = { backgroundColor: '#2941a4', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px', margin: '12px 0 18px' }
|
||||||
|
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from 'npm:react@18.3.1'
|
||||||
|
import { Body, Container, Head, Heading, Html, Preview, Text, Button } from 'npm:@react-email/components@0.0.22'
|
||||||
|
import type { TemplateEntry } from './registry.ts'
|
||||||
|
|
||||||
|
const SITE_NAME = 'Avria Community Management'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ownerName?: string
|
||||||
|
electionTitle?: string
|
||||||
|
associationName?: string
|
||||||
|
deadline?: string
|
||||||
|
votingUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ElectionInviteEmail = ({ ownerName, electionTitle, associationName, deadline, votingUrl }: Props) => (
|
||||||
|
<Html lang="en" dir="ltr">
|
||||||
|
<Head />
|
||||||
|
<Preview>Cast your vote — {electionTitle || 'Election'}</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Heading style={h1}>Election Ballot — {electionTitle || 'Election'}</Heading>
|
||||||
|
<Text style={text}>Dear {ownerName || 'Owner'},</Text>
|
||||||
|
<Text style={text}>
|
||||||
|
You are eligible to vote in the <strong>{electionTitle || 'election'}</strong>
|
||||||
|
{associationName ? ` for ${associationName}` : ''}.
|
||||||
|
</Text>
|
||||||
|
{deadline && <Text style={text}>⏰ <strong>Deadline:</strong> {deadline}</Text>}
|
||||||
|
<Button href={votingUrl || '#'} style={button}>Cast Your Vote</Button>
|
||||||
|
<Text style={smallText}>This is a secure, tokenized link tied to your unit. Your vote is anonymous — we record that you voted but not how you voted.</Text>
|
||||||
|
<Text style={smallText}>You may change your vote until the deadline by clicking the link again.</Text>
|
||||||
|
<Text style={footer}>If you did not expect this email, please contact your association manager. — {SITE_NAME}</Text>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const template = {
|
||||||
|
component: ElectionInviteEmail,
|
||||||
|
subject: (d: Record<string, any>) =>
|
||||||
|
`Vote Now: ${d.electionTitle || 'Election'}${d.associationName ? ` — ${d.associationName}` : ''}`,
|
||||||
|
displayName: 'Election invite',
|
||||||
|
previewData: { ownerName: 'Jane Owner', electionTitle: '2026 Board Election', associationName: 'Sample HOA', deadline: 'January 15, 2026 5:00 PM', votingUrl: 'https://avria.cloud/vote/xxx?token=yyy' },
|
||||||
|
} satisfies TemplateEntry
|
||||||
|
|
||||||
|
const main = { backgroundColor: '#ffffff', fontFamily: 'Inter, Arial, sans-serif' }
|
||||||
|
const container = { maxWidth: '600px', margin: '0 auto', padding: '28px 24px' }
|
||||||
|
const h1 = { color: '#111827', fontSize: '22px', lineHeight: '30px', margin: '0 0 14px', fontWeight: '700' }
|
||||||
|
const text = { color: '#374151', fontSize: '15px', lineHeight: '24px', margin: '0 0 14px' }
|
||||||
|
const smallText = { color: '#6b7280', fontSize: '13px', lineHeight: '20px', margin: '0 0 10px' }
|
||||||
|
const button = { backgroundColor: '#2941a4', color: '#ffffff', borderRadius: '6px', fontSize: '14px', fontWeight: '600', textDecoration: 'none', padding: '12px 18px', margin: '12px 0 18px' }
|
||||||
|
const footer = { color: '#6b7280', fontSize: '12px', lineHeight: '18px', margin: '28px 0 0' }
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/// <reference types="npm:@types/react@18.3.1" />
|
||||||
|
import * as React from 'npm:react@18.3.1'
|
||||||
|
|
||||||
|
export interface TemplateEntry {
|
||||||
|
component: React.ComponentType<any>
|
||||||
|
subject: string | ((data: Record<string, any>) => string)
|
||||||
|
to?: string
|
||||||
|
displayName?: string
|
||||||
|
previewData?: Record<string, any>
|
||||||
|
}
|
||||||
|
import { template as tenantInfoRequest } from './tenant-info-request.tsx'
|
||||||
|
import { template as ticketSubmitted } from './ticket-submitted.tsx'
|
||||||
|
import { template as ticketResponse } from './ticket-response.tsx'
|
||||||
|
import { template as vendorInsuranceRequest } from './vendor-insurance-request.tsx'
|
||||||
|
import { template as vendorProfileRequest } from './vendor-profile-request.tsx'
|
||||||
|
import { template as signupCodeInvite } from './signup-code-invite.tsx'
|
||||||
|
import { template as taskNotification } from './task-notification.tsx'
|
||||||
|
import { template as electionInvite } from './election-invite.tsx'
|
||||||
|
import { template as boardVoteInvite } from './board-vote-invite.tsx'
|
||||||
|
import { template as billApprovalRequest } from './bill-approval-request.tsx'
|
||||||
|
import { template as billApprovalVoteInvite } from './bill-approval-vote-invite.tsx'
|
||||||
|
|
||||||
|
export const TEMPLATES: Record<string, TemplateEntry> = {
|
||||||
|
'ticket-submitted': ticketSubmitted,
|
||||||
|
'ticket-response': ticketResponse,
|
||||||
|
'vendor-insurance-request': vendorInsuranceRequest,
|
||||||
|
'vendor-profile-request': vendorProfileRequest,
|
||||||
|
'signup-code-invite': signupCodeInvite,
|
||||||
|
'task-notification': taskNotification,
|
||||||
|
'election-invite': electionInvite,
|
||||||
|
'board-vote-invite': boardVoteInvite,
|
||||||
|
'tenant-info-request': tenantInfoRequest,
|
||||||
|
'bill-approval-request': billApprovalRequest,
|
||||||
|
'bill-approval-vote-invite': billApprovalVoteInvite,
|
||||||
|
}
|
||||||
@@ -118,6 +118,10 @@ serve(async (req) => {
|
|||||||
const denyLink = `${reviewLink}&action=deny`;
|
const denyLink = `${reviewLink}&action=deny`;
|
||||||
|
|
||||||
const { error: sendErr } = await admin.functions.invoke("send-transactional-email", {
|
const { error: sendErr } = await admin.functions.invoke("send-transactional-email", {
|
||||||
|
// Explicit Authorization header — supabase-js doesn't reliably
|
||||||
|
// forward the service-role apikey when one edge function invokes
|
||||||
|
// another, so verify_jwt on the inner function returns 401.
|
||||||
|
headers: { Authorization: `Bearer ${serviceKey}` },
|
||||||
body: {
|
body: {
|
||||||
templateName: "bill-approval-vote-invite",
|
templateName: "bill-approval-vote-invite",
|
||||||
recipientEmail: m.member_email,
|
recipientEmail: m.member_email,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
|
||||||
-- Per-bill, per-board-member secure approval tokens for email approve/deny
|
-- Per-bill, per-board-member secure approval tokens for email approve/deny.
|
||||||
|
-- Updated to use approver_name (post bill_approvals.vendor_name rename) and
|
||||||
|
-- populate approved_by/created_by from the board member's user_id.
|
||||||
CREATE TABLE IF NOT EXISTS public.bill_approval_email_tokens (
|
CREATE TABLE IF NOT EXISTS public.bill_approval_email_tokens (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bill_id uuid NOT NULL REFERENCES public.bills(id) ON DELETE CASCADE,
|
bill_id uuid NOT NULL REFERENCES public.bills(id) ON DELETE CASCADE,
|
||||||
@@ -18,12 +20,15 @@ CREATE INDEX IF NOT EXISTS idx_baet_token ON public.bill_approval_email_tokens(t
|
|||||||
CREATE INDEX IF NOT EXISTS idx_baet_bill ON public.bill_approval_email_tokens(bill_id);
|
CREATE INDEX IF NOT EXISTS idx_baet_bill ON public.bill_approval_email_tokens(bill_id);
|
||||||
|
|
||||||
ALTER TABLE public.bill_approval_email_tokens ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE public.bill_approval_email_tokens ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS "Staff manage bill approval tokens" ON public.bill_approval_email_tokens;
|
||||||
CREATE POLICY "Staff manage bill approval tokens" ON public.bill_approval_email_tokens
|
CREATE POLICY "Staff manage bill approval tokens" ON public.bill_approval_email_tokens
|
||||||
TO authenticated
|
TO authenticated
|
||||||
USING (has_role(auth.uid(),'admin') OR has_role(auth.uid(),'manager'))
|
USING (has_role(auth.uid(),'admin') OR has_role(auth.uid(),'manager'))
|
||||||
WITH CHECK (has_role(auth.uid(),'admin') OR has_role(auth.uid(),'manager'));
|
WITH CHECK (has_role(auth.uid(),'admin') OR has_role(auth.uid(),'manager'));
|
||||||
|
|
||||||
-- Lookup RPC for the public page (anonymous-friendly, security definer)
|
-- Lookup RPC for the public page (anonymous-friendly, security definer).
|
||||||
|
-- The "vendor_name" returned here is the bill's vendor (from public.vendors),
|
||||||
|
-- not the approver.
|
||||||
CREATE OR REPLACE FUNCTION public.lookup_bill_approval_by_token(p_token uuid)
|
CREATE OR REPLACE FUNCTION public.lookup_bill_approval_by_token(p_token uuid)
|
||||||
RETURNS TABLE (
|
RETURNS TABLE (
|
||||||
bill_id uuid,
|
bill_id uuid,
|
||||||
@@ -54,14 +59,20 @@ AS $$
|
|||||||
$$;
|
$$;
|
||||||
GRANT EXECUTE ON FUNCTION public.lookup_bill_approval_by_token(uuid) TO anon, authenticated;
|
GRANT EXECUTE ON FUNCTION public.lookup_bill_approval_by_token(uuid) TO anon, authenticated;
|
||||||
|
|
||||||
-- Record action via token
|
-- Record action via token. Anonymous callers identify via the secret token;
|
||||||
CREATE OR REPLACE FUNCTION public.record_bill_approval_by_token(p_token uuid, p_action text, p_notes text DEFAULT NULL)
|
-- the linked board_member.user_id (if any) is the audit identity we record.
|
||||||
|
CREATE OR REPLACE FUNCTION public.record_bill_approval_by_token(
|
||||||
|
p_token uuid,
|
||||||
|
p_action text,
|
||||||
|
p_notes text DEFAULT NULL
|
||||||
|
)
|
||||||
RETURNS jsonb
|
RETURNS jsonb
|
||||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||||
AS $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
tok record;
|
tok bill_approval_email_tokens%ROWTYPE;
|
||||||
bill record;
|
bill bills%ROWTYPE;
|
||||||
|
v_user_id uuid;
|
||||||
BEGIN
|
BEGIN
|
||||||
IF p_action NOT IN ('approved','denied') THEN
|
IF p_action NOT IN ('approved','denied') THEN
|
||||||
RETURN jsonb_build_object('ok', false, 'error', 'Invalid action');
|
RETURN jsonb_build_object('ok', false, 'error', 'Invalid action');
|
||||||
@@ -72,7 +83,10 @@ BEGIN
|
|||||||
RETURN jsonb_build_object('ok', false, 'error', 'Invalid token');
|
RETURN jsonb_build_object('ok', false, 'error', 'Invalid token');
|
||||||
END IF;
|
END IF;
|
||||||
IF tok.acted_at IS NOT NULL THEN
|
IF tok.acted_at IS NOT NULL THEN
|
||||||
RETURN jsonb_build_object('ok', false, 'already_voted', true, 'error', 'You have already responded', 'action', tok.action);
|
RETURN jsonb_build_object(
|
||||||
|
'ok', false, 'already_voted', true,
|
||||||
|
'error', 'You have already responded', 'action', tok.action
|
||||||
|
);
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
SELECT * INTO bill FROM bills WHERE id = tok.bill_id;
|
SELECT * INTO bill FROM bills WHERE id = tok.bill_id;
|
||||||
@@ -80,15 +94,22 @@ BEGIN
|
|||||||
RETURN jsonb_build_object('ok', false, 'error', 'Bill not found');
|
RETURN jsonb_build_object('ok', false, 'error', 'Bill not found');
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
INSERT INTO bill_approvals (bill_id, association_id, vendor_name, amount, status, notes, approved_date)
|
SELECT user_id INTO v_user_id FROM board_members WHERE id = tok.board_member_id;
|
||||||
VALUES (tok.bill_id, bill.association_id, COALESCE(tok.member_name,'Board Member'), bill.amount,
|
|
||||||
p_action, p_notes, CURRENT_DATE);
|
|
||||||
|
|
||||||
UPDATE bills SET status = p_action, updated_at = now() WHERE id = tok.bill_id;
|
INSERT INTO bill_approvals (
|
||||||
|
bill_id, association_id, approver_name, amount,
|
||||||
|
status, notes, approved_date, approved_by, created_by
|
||||||
|
) VALUES (
|
||||||
|
tok.bill_id, bill.association_id, COALESCE(tok.member_name, 'Board Member'), bill.amount,
|
||||||
|
p_action, p_notes, CURRENT_DATE, v_user_id, v_user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- public.bills.status is derived by the recompute_bill_status_from_approvals
|
||||||
|
-- trigger from the votes — don't force-update here.
|
||||||
|
|
||||||
UPDATE bill_approval_email_tokens
|
UPDATE bill_approval_email_tokens
|
||||||
SET acted_at = now(), action = p_action, notes = p_notes
|
SET acted_at = now(), action = p_action, notes = p_notes
|
||||||
WHERE token = p_token;
|
WHERE token = p_token;
|
||||||
|
|
||||||
RETURN jsonb_build_object('ok', true, 'action', p_action);
|
RETURN jsonb_build_object('ok', true, 'action', p_action);
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Two-way sync: public.bills <-> accounting.bills
|
||||||
|
--
|
||||||
|
-- Forward: public.bills changes -> upsert accounting.bills (linked via
|
||||||
|
-- external_source='acmacc_bill', external_id=public.bills.id::text).
|
||||||
|
-- Approvals reach accounting indirectly: the existing
|
||||||
|
-- recompute_bill_status_from_approvals trigger flips
|
||||||
|
-- public.bills.status, which then fires the forward trigger below.
|
||||||
|
--
|
||||||
|
-- Reverse: accounting.bills.status='void' -> public.bills.status='cancelled'.
|
||||||
|
-- accounting.bills paid sync (paid_amount >= total) is already
|
||||||
|
-- handled by accounting.sync_accounting_bill_paid; left untouched.
|
||||||
|
--
|
||||||
|
-- Note: the forward sync function and trigger added here are dropped by the
|
||||||
|
-- next migration (drop_redundant_bills_forward_sync) because the existing
|
||||||
|
-- accounting.sync_public_bill / trg_acct_sync_public_bill chain already does
|
||||||
|
-- the forward direction more thoroughly (vendor auto-create, bill_items,
|
||||||
|
-- GL posting). The reverse trigger and index stay.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- Fast lookups for the external link used by both sync directions.
|
||||||
|
CREATE INDEX IF NOT EXISTS accounting_bills_external_lookup_idx
|
||||||
|
ON accounting.bills (external_source, external_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Forward sync function
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION accounting.sync_public_bill_to_accounting(_public_bill_id uuid)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'accounting'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
pb public.bills%ROWTYPE;
|
||||||
|
v_company_id uuid;
|
||||||
|
v_vendor_id uuid;
|
||||||
|
v_status accounting.bill_status;
|
||||||
|
v_existing accounting.bills%ROWTYPE;
|
||||||
|
v_number text;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO pb FROM public.bills WHERE id = _public_bill_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Resolve accounting company for the association (try ensure_ then fallback)
|
||||||
|
BEGIN
|
||||||
|
v_company_id := accounting.ensure_company_for_association(pb.association_id);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
BEGIN
|
||||||
|
v_company_id := accounting.company_id_for_association(pb.association_id);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE WARNING 'accounting sync: cannot resolve company for association % (bill %): %',
|
||||||
|
pb.association_id, pb.id, SQLERRM;
|
||||||
|
RETURN;
|
||||||
|
END;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF v_company_id IS NULL THEN
|
||||||
|
RAISE WARNING 'accounting sync: no company resolved for association % (bill %)',
|
||||||
|
pb.association_id, pb.id;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Status mapping
|
||||||
|
v_status := CASE pb.status
|
||||||
|
WHEN 'approved' THEN 'open'::accounting.bill_status
|
||||||
|
WHEN 'paid' THEN 'paid'::accounting.bill_status
|
||||||
|
WHEN 'denied' THEN 'void'::accounting.bill_status
|
||||||
|
WHEN 'cancelled' THEN 'void'::accounting.bill_status
|
||||||
|
WHEN 'pending' THEN 'draft'::accounting.bill_status
|
||||||
|
WHEN 'draft' THEN 'draft'::accounting.bill_status
|
||||||
|
ELSE 'draft'::accounting.bill_status
|
||||||
|
END;
|
||||||
|
|
||||||
|
SELECT * INTO v_existing
|
||||||
|
FROM accounting.bills
|
||||||
|
WHERE external_source = 'acmacc_bill' AND external_id = pb.id::text;
|
||||||
|
|
||||||
|
-- Look up matching accounting vendor (NULL if none — column is nullable)
|
||||||
|
IF pb.vendor_id IS NOT NULL THEN
|
||||||
|
SELECT id INTO v_vendor_id
|
||||||
|
FROM accounting.vendors
|
||||||
|
WHERE company_id = v_company_id
|
||||||
|
AND external_source = 'acmacc_vendor'
|
||||||
|
AND external_id = pb.vendor_id::text
|
||||||
|
LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_number := COALESCE(NULLIF(pb.invoice_number, ''), 'BILL-' || substr(pb.id::text, 1, 8));
|
||||||
|
|
||||||
|
IF v_existing.id IS NULL THEN
|
||||||
|
-- Only materialize the accounting row once the public bill has reached
|
||||||
|
-- a state worth posting; skip pure-draft creations.
|
||||||
|
IF pb.status NOT IN ('approved', 'paid', 'denied', 'cancelled') THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO accounting.bills (
|
||||||
|
company_id, vendor_id, number, issue_date, due_date,
|
||||||
|
status, subtotal, tax, total, paid_amount, notes,
|
||||||
|
external_source, external_id
|
||||||
|
) VALUES (
|
||||||
|
v_company_id, v_vendor_id, v_number, pb.bill_date, pb.due_date,
|
||||||
|
v_status, pb.amount, 0, pb.amount, pb.amount_paid, pb.notes,
|
||||||
|
'acmacc_bill', pb.id::text
|
||||||
|
);
|
||||||
|
ELSE
|
||||||
|
UPDATE accounting.bills SET
|
||||||
|
vendor_id = COALESCE(v_vendor_id, vendor_id),
|
||||||
|
number = v_number,
|
||||||
|
issue_date = pb.bill_date,
|
||||||
|
due_date = pb.due_date,
|
||||||
|
status = v_status,
|
||||||
|
subtotal = pb.amount,
|
||||||
|
total = pb.amount,
|
||||||
|
paid_amount = pb.amount_paid,
|
||||||
|
notes = pb.notes,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_existing.id
|
||||||
|
AND (
|
||||||
|
v_existing.vendor_id IS DISTINCT FROM COALESCE(v_vendor_id, v_existing.vendor_id)
|
||||||
|
OR v_existing.number IS DISTINCT FROM v_number
|
||||||
|
OR v_existing.issue_date IS DISTINCT FROM pb.bill_date
|
||||||
|
OR v_existing.due_date IS DISTINCT FROM pb.due_date
|
||||||
|
OR v_existing.status IS DISTINCT FROM v_status
|
||||||
|
OR v_existing.subtotal IS DISTINCT FROM pb.amount
|
||||||
|
OR v_existing.total IS DISTINCT FROM pb.amount
|
||||||
|
OR v_existing.paid_amount IS DISTINCT FROM pb.amount_paid
|
||||||
|
OR v_existing.notes IS DISTINCT FROM pb.notes
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Forward trigger
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION public.tg_bills_sync_to_accounting()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
BEGIN
|
||||||
|
PERFORM accounting.sync_public_bill_to_accounting(NEW.id);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
-- Never break the originating bill write because of a sync failure.
|
||||||
|
RAISE WARNING 'public.bills -> accounting.bills sync failed for %: %', NEW.id, SQLERRM;
|
||||||
|
END;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_public_bills_sync_accounting ON public.bills;
|
||||||
|
CREATE TRIGGER trg_public_bills_sync_accounting
|
||||||
|
AFTER INSERT OR UPDATE OF
|
||||||
|
status, amount, amount_paid, bill_date, due_date, vendor_id, invoice_number, notes
|
||||||
|
ON public.bills
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.tg_bills_sync_to_accounting();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Reverse trigger: accounting.bills.status='void' -> public.bills.status='cancelled'
|
||||||
|
-- (paid back-sync is already done by accounting.sync_accounting_bill_paid)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION accounting.tg_accounting_bill_status_back_sync()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path TO 'public', 'accounting'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
_public_id uuid;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.external_source IS DISTINCT FROM 'acmacc_bill' OR NEW.external_id IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
IF OLD.status IS NOT DISTINCT FROM NEW.status THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
_public_id := NEW.external_id::uuid;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF NEW.status = 'void' THEN
|
||||||
|
UPDATE public.bills
|
||||||
|
SET status = 'cancelled', updated_at = now()
|
||||||
|
WHERE id = _public_id
|
||||||
|
AND status NOT IN ('paid', 'cancelled');
|
||||||
|
|
||||||
|
UPDATE public.bill_approvals
|
||||||
|
SET status = 'cancelled', updated_at = now()
|
||||||
|
WHERE bill_id = _public_id
|
||||||
|
AND status IS DISTINCT FROM 'cancelled';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_acct_bill_status_back_sync ON accounting.bills;
|
||||||
|
CREATE TRIGGER trg_acct_bill_status_back_sync
|
||||||
|
AFTER UPDATE OF status ON accounting.bills
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION accounting.tg_accounting_bill_status_back_sync();
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- The forward sync added in 20260604191117 duplicates the pre-existing
|
||||||
|
-- accounting.sync_public_bill / trg_acct_sync_public_bill chain (which also
|
||||||
|
-- creates accounting.bill_items, posts the GL, and auto-creates
|
||||||
|
-- accounting.vendors). Removing the redundant copy.
|
||||||
|
-- The reverse trigger (trg_acct_bill_status_back_sync) and the external lookup
|
||||||
|
-- index are kept.
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_public_bills_sync_accounting ON public.bills;
|
||||||
|
DROP FUNCTION IF EXISTS public.tg_bills_sync_to_accounting();
|
||||||
|
DROP FUNCTION IF EXISTS accounting.sync_public_bill_to_accounting(uuid);
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
-- bill_approvals.vendor_name is misnamed — it actually stores the approver's
|
||||||
|
-- person name (e.g. board member name), not a vendor. Renaming for clarity.
|
||||||
|
-- RENAME COLUMN automatically rewrites references in dependent RLS policies,
|
||||||
|
-- triggers, and indexes.
|
||||||
|
ALTER TABLE public.bill_approvals RENAME COLUMN vendor_name TO approver_name;
|
||||||
|
COMMENT ON COLUMN public.bill_approvals.approver_name IS
|
||||||
|
'Display name of the approver (typically a board_members.member_name). Previously named vendor_name.';
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
-- Per-bill, per-board-member secure approval tokens for email approve/deny.
|
||||||
|
-- Recreated to use approver_name (post-rename) and populate approved_by/created_by
|
||||||
|
-- from the linked board member's user_id (audit trail for who clicked the email link).
|
||||||
|
-- The earlier 20260520153409 migration was never applied successfully; this is the
|
||||||
|
-- corrected idempotent version.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.bill_approval_email_tokens (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
bill_id uuid NOT NULL REFERENCES public.bills(id) ON DELETE CASCADE,
|
||||||
|
board_member_id uuid NOT NULL REFERENCES public.board_members(id) ON DELETE CASCADE,
|
||||||
|
token uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
|
||||||
|
email text NOT NULL,
|
||||||
|
member_name text,
|
||||||
|
sent_at timestamptz,
|
||||||
|
acted_at timestamptz,
|
||||||
|
action text CHECK (action IN ('approved','denied')),
|
||||||
|
notes text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (bill_id, board_member_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_baet_token ON public.bill_approval_email_tokens(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_baet_bill ON public.bill_approval_email_tokens(bill_id);
|
||||||
|
|
||||||
|
ALTER TABLE public.bill_approval_email_tokens ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS "Staff manage bill approval tokens" ON public.bill_approval_email_tokens;
|
||||||
|
CREATE POLICY "Staff manage bill approval tokens" ON public.bill_approval_email_tokens
|
||||||
|
TO authenticated
|
||||||
|
USING (has_role(auth.uid(),'admin') OR has_role(auth.uid(),'manager'))
|
||||||
|
WITH CHECK (has_role(auth.uid(),'admin') OR has_role(auth.uid(),'manager'));
|
||||||
|
|
||||||
|
-- Lookup RPC for the public page (anonymous-friendly, security definer).
|
||||||
|
-- Note: the "vendor_name" returned here is the bill's vendor (from public.vendors),
|
||||||
|
-- NOT the approver. That naming is for the public landing page UI.
|
||||||
|
CREATE OR REPLACE FUNCTION public.lookup_bill_approval_by_token(p_token uuid)
|
||||||
|
RETURNS TABLE (
|
||||||
|
bill_id uuid,
|
||||||
|
member_name text,
|
||||||
|
member_email text,
|
||||||
|
acted_at timestamptz,
|
||||||
|
action text,
|
||||||
|
vendor_name text,
|
||||||
|
invoice_number text,
|
||||||
|
bill_date date,
|
||||||
|
due_date date,
|
||||||
|
amount numeric,
|
||||||
|
description text,
|
||||||
|
attachment_url text,
|
||||||
|
bill_status text,
|
||||||
|
association_name text
|
||||||
|
)
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT t.bill_id, t.member_name, t.email AS member_email, t.acted_at, t.action,
|
||||||
|
v.name AS vendor_name, b.invoice_number, b.bill_date, b.due_date, b.amount,
|
||||||
|
b.description, b.attachment_url, b.status AS bill_status, a.name AS association_name
|
||||||
|
FROM bill_approval_email_tokens t
|
||||||
|
JOIN bills b ON b.id = t.bill_id
|
||||||
|
LEFT JOIN vendors v ON v.id = b.vendor_id
|
||||||
|
LEFT JOIN associations a ON a.id = b.association_id
|
||||||
|
WHERE t.token = p_token;
|
||||||
|
$$;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.lookup_bill_approval_by_token(uuid) TO anon, authenticated;
|
||||||
|
|
||||||
|
-- Record action via token. Anonymous callers identify via the secret token;
|
||||||
|
-- the linked board_member.user_id (if any) is the audit identity we record.
|
||||||
|
CREATE OR REPLACE FUNCTION public.record_bill_approval_by_token(
|
||||||
|
p_token uuid,
|
||||||
|
p_action text,
|
||||||
|
p_notes text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
tok bill_approval_email_tokens%ROWTYPE;
|
||||||
|
bill bills%ROWTYPE;
|
||||||
|
v_user_id uuid;
|
||||||
|
BEGIN
|
||||||
|
IF p_action NOT IN ('approved','denied') THEN
|
||||||
|
RETURN jsonb_build_object('ok', false, 'error', 'Invalid action');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT * INTO tok FROM bill_approval_email_tokens WHERE token = p_token;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN jsonb_build_object('ok', false, 'error', 'Invalid token');
|
||||||
|
END IF;
|
||||||
|
IF tok.acted_at IS NOT NULL THEN
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'ok', false, 'already_voted', true,
|
||||||
|
'error', 'You have already responded', 'action', tok.action
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT * INTO bill FROM bills WHERE id = tok.bill_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN jsonb_build_object('ok', false, 'error', 'Bill not found');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT user_id INTO v_user_id FROM board_members WHERE id = tok.board_member_id;
|
||||||
|
|
||||||
|
INSERT INTO bill_approvals (
|
||||||
|
bill_id, association_id, approver_name, amount,
|
||||||
|
status, notes, approved_date, approved_by, created_by
|
||||||
|
) VALUES (
|
||||||
|
tok.bill_id, bill.association_id, COALESCE(tok.member_name, 'Board Member'), bill.amount,
|
||||||
|
p_action, p_notes, CURRENT_DATE, v_user_id, v_user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Note: the recompute_bill_status_from_approvals trigger on bill_approvals
|
||||||
|
-- will derive public.bills.status from the votes (denied / approved when
|
||||||
|
-- threshold met). We do NOT force-update bills.status here.
|
||||||
|
|
||||||
|
UPDATE bill_approval_email_tokens
|
||||||
|
SET acted_at = now(), action = p_action, notes = p_notes
|
||||||
|
WHERE token = p_token;
|
||||||
|
|
||||||
|
RETURN jsonb_build_object('ok', true, 'action', p_action);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.record_bill_approval_by_token(uuid, text, text) TO anon, authenticated;
|
||||||
Reference in New Issue
Block a user