From 2c723410a4290f23cddd25727038497802e204d4 Mon Sep 17 00:00:00 2001 From: renee-png Date: Thu, 4 Jun 2026 17:17:05 -0400 Subject: [PATCH] Bill approvals: surface approvers, fix email path, schema cleanup UI - Dashboard BillApprovalsCard: render approver name chips (color-coded by vote status) per pending bill instead of leaving the approver identity invisible. - BillDetailPage: collapse the duplicate "Requested Approvers" card into the existing "Approvers" table. Approve/deny handler now stamps approved_by = auth.uid() for audit trail. - MasterBoardDashboardPage: the "pending approvals for me" count was filtering on a non-existent bill_approvals.approver_user_id column. Replaced with a board_members.member_name -> bill_approvals.approver_name join (matches the RLS policy). - BillApprovalRequestDialog + AIInvoiceParserPage: bill_approvals inserts now set created_by. Database - Rename public.bill_approvals.vendor_name -> approver_name. RLS policies auto-rewritten by ALTER TABLE RENAME COLUMN; the column was misnamed (it stores the approver's board-member name, never a vendor). - Restore the bill_approval_email_tokens table + lookup_/record_ bill_approval_by_token RPCs. The original 20260520153409 migration was never applied successfully; rewrote it to use approver_name and to populate approved_by/created_by from board_members.user_id on token-driven votes. Added the v2 migration that matches the live DB state. - accounting trigger: void on accounting.bills cascades to public.bills.status='cancelled' (existing forward sync then drops the accounting row per accounting.bill_should_mirror). Edge function - send-transactional-email: add bill-approval-request and bill-approval-vote-invite templates (caller paths in BillApprovalsPage + send-bill-approval-invites referenced templates that weren't in the registry, so every email 404'd). Restored the local copies of election-invite, board-vote-invite, and the missing registry.ts so the repo matches what's deployed. Deployed to send-transactional-email v35. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/BillApprovalRequestDialog.jsx | 6 +- .../dashboard/BillApprovalsCard.tsx | 50 ++++- src/hooks/useBillApprovals.js | 4 +- src/integrations/supabase/types.ts | 6 +- src/pages/AIInvoiceParserPage.tsx | 3 +- src/pages/BillApprovalsPage.tsx | 8 +- src/pages/BillDetailPage.tsx | 69 +----- .../master-board/MasterBoardDashboardPage.tsx | 34 ++- .../bill-approval-request.tsx | 59 +++++ .../bill-approval-vote-invite.tsx | 119 ++++++++++ .../board-vote-invite.tsx | 52 +++++ .../election-invite.tsx | 51 +++++ .../transactional-email-templates/registry.ts | 35 +++ ...9_c92ad975-6112-4313-82a0-081da18d6547.sql | 47 ++-- ..._sync_public_bills_to_accounting_bills.sql | 211 ++++++++++++++++++ ...2000_drop_redundant_bills_forward_sync.sql | 10 + ...approvals_vendor_name_to_approver_name.sql | 7 + ...04203329_bill_approval_email_tokens_v2.sql | 120 ++++++++++ 18 files changed, 796 insertions(+), 95 deletions(-) create mode 100644 supabase/functions/_shared/transactional-email-templates/bill-approval-request.tsx create mode 100644 supabase/functions/_shared/transactional-email-templates/bill-approval-vote-invite.tsx create mode 100644 supabase/functions/_shared/transactional-email-templates/board-vote-invite.tsx create mode 100644 supabase/functions/_shared/transactional-email-templates/election-invite.tsx create mode 100644 supabase/functions/_shared/transactional-email-templates/registry.ts create mode 100644 supabase/migrations/20260604191117_sync_public_bills_to_accounting_bills.sql create mode 100644 supabase/migrations/20260604192000_drop_redundant_bills_forward_sync.sql create mode 100644 supabase/migrations/20260604200249_rename_bill_approvals_vendor_name_to_approver_name.sql create mode 100644 supabase/migrations/20260604203329_bill_approval_email_tokens_v2.sql diff --git a/src/components/BillApprovalRequestDialog.jsx b/src/components/BillApprovalRequestDialog.jsx index 62473d1..2ed53bb 100644 --- a/src/components/BillApprovalRequestDialog.jsx +++ b/src/components/BillApprovalRequestDialog.jsx @@ -60,14 +60,18 @@ export default function BillApprovalRequestDialog({ open, onOpenChange, billId, try { 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 bm = boardMembers.find(b => b.id === memberId); return { association_id: clientId, bill_id: billId, - vendor_name: bm?.member_name || 'Approver', + approver_name: bm?.member_name || 'Approver', status: 'pending', notes: comment || null, + created_by: createdBy, }; }); diff --git a/src/components/dashboard/BillApprovalsCard.tsx b/src/components/dashboard/BillApprovalsCard.tsx index 3e7f25a..8576025 100644 --- a/src/components/dashboard/BillApprovalsCard.tsx +++ b/src/components/dashboard/BillApprovalsCard.tsx @@ -8,6 +8,11 @@ import { supabase } from "@/integrations/supabase/client"; import { useNavigate } from "react-router-dom"; import { formatDistanceToNow } from "date-fns"; +interface BillApprover { + name: string; + status: string; +} + interface PendingBill { id: string; source_invoice_id?: string | null; @@ -17,6 +22,7 @@ interface PendingBill { created_at: string; associations?: { name: string } | null; vendor_name?: string | null; + approvers: BillApprover[]; source: "bill" | "invoice"; } @@ -41,7 +47,7 @@ type PendingBillDbRow = Omit & { created_at: string | 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) => typeof n === "number" @@ -83,9 +89,23 @@ const normalizePendingBill = (bill: PendingBillDbRow): PendingBill => ({ created_at: bill.created_at, associations: bill.associations, vendor_name: bill.invoices?.vendor_name ?? bill.vendors?.name ?? bill.notes ?? null, + approvers: [], 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() { const navigate = useNavigate(); const [tab, setTab] = useState<"approvals" | "inbox">("approvals"); @@ -115,7 +135,7 @@ export function BillApprovalsCard() { // approved/denied). Those should not contribute to the badge. supabase .from("bill_approvals") - .select("bill_id, status") + .select("bill_id, status, approver_name") .not("bill_id", "is", null), ]); @@ -126,11 +146,20 @@ export function BillApprovalsCard() { // Group approvals by bill_id and figure out which pending bills are // truly actionable (have ≥1 pending approval, or none at all). const approvalsByBill = new Map(); + const approversByBill = new Map(); billApprovalStatusRows.forEach((a) => { if (!a.bill_id) return; const arr = approvalsByBill.get(a.bill_id) || []; if (a.status) arr.push(a.status); 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[] = []; let actionablePendingBills = 0; @@ -312,6 +341,23 @@ export function BillApprovalsCard() { {formatDistanceToNow(new Date(bill.created_at), { addSuffix: true })} + {bill.approvers.length > 0 ? ( +
+ {bill.approvers.map((ap, i) => ( + + {ap.name} + + ))} +
+ ) : ( +

+ No approvers requested +

+ )} ))} diff --git a/src/hooks/useBillApprovals.js b/src/hooks/useBillApprovals.js index bb55007..cad8697 100644 --- a/src/hooks/useBillApprovals.js +++ b/src/hooks/useBillApprovals.js @@ -64,7 +64,7 @@ export function useBillApprovals() { approved_by: userData?.user?.id, status: 'approved', notes: comment, - vendor_name: voterName || 'Unknown', + approver_name: voterName || 'Unknown', association_id: (bills.find(b => b.id === billId))?.association_id }) .select() @@ -76,7 +76,7 @@ export function useBillApprovals() { approved_by: userData?.user?.id, status: 'denied', notes: comment, - vendor_name: voterName || 'Unknown', + approver_name: voterName || 'Unknown', association_id: (bills.find(b => b.id === billId))?.association_id }); if (approvalError) throw approvalError; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index afa650a..4a293bb 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -1772,6 +1772,7 @@ export type Database = { amount: number approved_by: string | null approved_date: string | null + approver_name: string association_id: string bill_id: string | null created_at: string @@ -1781,12 +1782,12 @@ export type Database = { notes: string | null status: string updated_at: string - vendor_name: string } Insert: { amount?: number approved_by?: string | null approved_date?: string | null + approver_name: string association_id: string bill_id?: string | null created_at?: string @@ -1796,12 +1797,12 @@ export type Database = { notes?: string | null status?: string updated_at?: string - vendor_name: string } Update: { amount?: number approved_by?: string | null approved_date?: string | null + approver_name?: string association_id?: string bill_id?: string | null created_at?: string @@ -1811,7 +1812,6 @@ export type Database = { notes?: string | null status?: string updated_at?: string - vendor_name?: string } Relationships: [ { diff --git a/src/pages/AIInvoiceParserPage.tsx b/src/pages/AIInvoiceParserPage.tsx index 9f30133..13556bf 100644 --- a/src/pages/AIInvoiceParserPage.tsx +++ b/src/pages/AIInvoiceParserPage.tsx @@ -370,11 +370,12 @@ export default function AIInvoiceParserPage() { return { association_id: reviewForm.association_id, 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, invoice_id: invoice.id, status: "pending", notes: description || null, + created_by: userData?.user?.id || null, }; }); await supabase.from("bill_approvals").insert(approvalRows); diff --git a/src/pages/BillApprovalsPage.tsx b/src/pages/BillApprovalsPage.tsx index e47270a..0ab1e6b 100644 --- a/src/pages/BillApprovalsPage.tsx +++ b/src/pages/BillApprovalsPage.tsx @@ -163,7 +163,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci .from("bill_approvals") .select("bill_id") .in("association_id", boardAssociationIds!) - .in("vendor_name", names) + .in("approver_name", names) .not("bill_id", "is", null); 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 { data: approvals } = await supabase .from("bill_approvals") - .select("id, bill_id, vendor_name, status") + .select("id, bill_id, approver_name, status") .in("bill_id", billIds); const grouped: Record = {}; (approvals || []).forEach((a: any) => { @@ -455,7 +455,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci return { association_id: form.association_id, bill_id: newBill?.id || null, - vendor_name: bm?.member_name || vendorDisplayName, + approver_name: bm?.member_name || vendorDisplayName, amount: parseFloat(form.amount) || 0, status: "pending", notes: form.description || null, @@ -1084,7 +1084,7 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci {a.status === 'approved' ? '✓' : a.status === 'denied' || a.status === 'rejected' ? '✗' : '⏳'} - {a.vendor_name} + {a.approver_name} ))} diff --git a/src/pages/BillDetailPage.tsx b/src/pages/BillDetailPage.tsx index 119d313..48dfe43 100644 --- a/src/pages/BillDetailPage.tsx +++ b/src/pages/BillDetailPage.tsx @@ -133,9 +133,14 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati const handleApprovalAction = async (approvalId: string, action: "approved" | "denied") => { try { + const { data: userData } = await supabase.auth.getUser(); const { error } = await supabase .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); if (error) throw error; @@ -170,7 +175,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati // Notify staff (admins/managers) that a board member has voted try { 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 associationName = bill?.associations?.name || "an association"; const verb = action === "approved" ? "approved" : "denied"; @@ -445,56 +450,6 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati - {/* Requested Approvers */} - - - - Requested Approvers - - - - {approvals.length === 0 ? ( -

- No specific approvers requested during creation. You can request one above. -

- ) : ( -
- {approvals.map((a) => ( -
-
-

{a.vendor_name}

-

- ${Number(a.amount).toFixed(2)} · {new Date(a.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} -

- {a.notes &&

{a.notes}

} -
-
- - {a.status.charAt(0).toUpperCase() + a.status.slice(1)} - - {a.status === "pending" && (boardAssociationIds ? userBoardMemberNames.includes(a.vendor_name) : true) && ( - <> - - - - )} - {!boardAssociationIds && ( - - )} -
-
- ))} -
- )} -
-
- {(lineItems.length > 0 || canEditLineItems) && ( @@ -674,17 +629,17 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati )} - {/* Approval History */} + {/* Approvers */} - Approval History + Approvers {approvals.length === 0 ? (
- No approval records yet. + No approvers requested yet. Use the “Request Approval” button above to add one.
) : ( @@ -702,7 +657,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
-

{a.vendor_name || "—"}

+

{a.approver_name || "—"}

@@ -724,7 +679,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
- {a.status === "pending" && (isBoardView ? userBoardMemberNames.includes(a.vendor_name) : true) && ( + {a.status === "pending" && (isBoardView ? userBoardMemberNames.includes(a.approver_name) : true) && ( <> } + + You may also have received individual emails for each bill — those contain one-click approve and + deny buttons tied uniquely to your seat. + + + If you weren't expecting this email, please contact your association manager. — {SITE_NAME} + + + + +) + +export const template = { + component: BillApprovalRequestEmail, + subject: (d: Record) => + `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' } diff --git a/supabase/functions/_shared/transactional-email-templates/bill-approval-vote-invite.tsx b/supabase/functions/_shared/transactional-email-templates/bill-approval-vote-invite.tsx new file mode 100644 index 0000000..0d09f77 --- /dev/null +++ b/supabase/functions/_shared/transactional-email-templates/bill-approval-vote-invite.tsx @@ -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) => ( + + + Approve or deny: {vendorName || 'Bill'} {invoiceNumber || ''} + + +
+ Bill approval requested + Hello {memberName || 'Board Member'}, + + A bill{associationName ? ` for ${associationName}` : ''} requires your approval. + + +
+ Vendor + {vendorName || '—'} + {invoiceNumber && ( + <> + Invoice # + {invoiceNumber} + + )} + Amount + {amount || '—'} + {(billDate || dueDate) && ( + <> + {billDate ? 'Bill date' : ''}{billDate && dueDate ? ' · Due date' : (dueDate ? 'Due date' : '')} + + {billDate || ''}{billDate && dueDate ? ' · ' : ''}{dueDate || ''} + + + )} + {description && ( + <> + Description + {description} + + )} +
+ +
+ {approveLink && } + {denyLink && } +
+ + {reviewLink && ( + + Prefer to review the full bill first? Open it here. + + )} + + 🔒 These approve / deny links are tied uniquely to your board seat. Please do not share or forward them. + + + If you weren't expecting this email, please contact your association manager. — {SITE_NAME} + + + + +) + +export const template = { + component: BillApprovalVoteInviteEmail, + subject: (d: Record) => + `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' } diff --git a/supabase/functions/_shared/transactional-email-templates/board-vote-invite.tsx b/supabase/functions/_shared/transactional-email-templates/board-vote-invite.tsx new file mode 100644 index 0000000..bf3d998 --- /dev/null +++ b/supabase/functions/_shared/transactional-email-templates/board-vote-invite.tsx @@ -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) => ( + + + Board vote — {voteTitle || 'Cast your vote'} + + + Board Vote: {voteTitle || 'New vote'} + Hello {memberName || 'Board Member'}, + + A board vote has been opened{associationName ? ` for ${associationName}` : ''}: + + {voteDescription && {voteDescription}} + Please cast your vote using the secure link below. + + 🔒 This is a secure link tied uniquely to your board seat. Please do not share or forward it. + For recording purposes only: This electronic vote will be formally ratified at a later board meeting. + If you did not expect this email, please contact your association manager. — {SITE_NAME} + + + +) + +export const template = { + component: BoardVoteInviteEmail, + subject: (d: Record) => + `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' } diff --git a/supabase/functions/_shared/transactional-email-templates/election-invite.tsx b/supabase/functions/_shared/transactional-email-templates/election-invite.tsx new file mode 100644 index 0000000..7f3ad60 --- /dev/null +++ b/supabase/functions/_shared/transactional-email-templates/election-invite.tsx @@ -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) => ( + + + Cast your vote — {electionTitle || 'Election'} + + + Election Ballot — {electionTitle || 'Election'} + Dear {ownerName || 'Owner'}, + + You are eligible to vote in the {electionTitle || 'election'} + {associationName ? ` for ${associationName}` : ''}. + + {deadline && Deadline: {deadline}} + + This is a secure, tokenized link tied to your unit. Your vote is anonymous — we record that you voted but not how you voted. + You may change your vote until the deadline by clicking the link again. + If you did not expect this email, please contact your association manager. — {SITE_NAME} + + + +) + +export const template = { + component: ElectionInviteEmail, + subject: (d: Record) => + `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' } diff --git a/supabase/functions/_shared/transactional-email-templates/registry.ts b/supabase/functions/_shared/transactional-email-templates/registry.ts new file mode 100644 index 0000000..55b88d6 --- /dev/null +++ b/supabase/functions/_shared/transactional-email-templates/registry.ts @@ -0,0 +1,35 @@ +/// +import * as React from 'npm:react@18.3.1' + +export interface TemplateEntry { + component: React.ComponentType + subject: string | ((data: Record) => string) + to?: string + displayName?: string + previewData?: Record +} +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 = { + '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, +} diff --git a/supabase/migrations/20260520153409_c92ad975-6112-4313-82a0-081da18d6547.sql b/supabase/migrations/20260520153409_c92ad975-6112-4313-82a0-081da18d6547.sql index 513c181..6ca547f 100644 --- a/supabase/migrations/20260520153409_c92ad975-6112-4313-82a0-081da18d6547.sql +++ b/supabase/migrations/20260520153409_c92ad975-6112-4313-82a0-081da18d6547.sql @@ -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 ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), 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); 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) +-- 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) RETURNS TABLE ( bill_id uuid, @@ -54,14 +59,20 @@ AS $$ $$; GRANT EXECUTE ON FUNCTION public.lookup_bill_approval_by_token(uuid) TO anon, authenticated; --- Record action via token -CREATE OR REPLACE FUNCTION public.record_bill_approval_by_token(p_token uuid, p_action text, p_notes text DEFAULT NULL) +-- 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 record; - bill record; + 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'); @@ -72,7 +83,10 @@ BEGIN 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); + 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; @@ -80,15 +94,22 @@ BEGIN RETURN jsonb_build_object('ok', false, 'error', 'Bill not found'); END IF; - INSERT INTO bill_approvals (bill_id, association_id, vendor_name, amount, status, notes, approved_date) - VALUES (tok.bill_id, bill.association_id, COALESCE(tok.member_name,'Board Member'), bill.amount, - p_action, p_notes, CURRENT_DATE); + SELECT user_id INTO v_user_id FROM board_members WHERE id = tok.board_member_id; - 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 - SET acted_at = now(), action = p_action, notes = p_notes - WHERE token = p_token; + SET acted_at = now(), action = p_action, notes = p_notes + WHERE token = p_token; RETURN jsonb_build_object('ok', true, 'action', p_action); END; diff --git a/supabase/migrations/20260604191117_sync_public_bills_to_accounting_bills.sql b/supabase/migrations/20260604191117_sync_public_bills_to_accounting_bills.sql new file mode 100644 index 0000000..4b358e6 --- /dev/null +++ b/supabase/migrations/20260604191117_sync_public_bills_to_accounting_bills.sql @@ -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(); diff --git a/supabase/migrations/20260604192000_drop_redundant_bills_forward_sync.sql b/supabase/migrations/20260604192000_drop_redundant_bills_forward_sync.sql new file mode 100644 index 0000000..349ed67 --- /dev/null +++ b/supabase/migrations/20260604192000_drop_redundant_bills_forward_sync.sql @@ -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); diff --git a/supabase/migrations/20260604200249_rename_bill_approvals_vendor_name_to_approver_name.sql b/supabase/migrations/20260604200249_rename_bill_approvals_vendor_name_to_approver_name.sql new file mode 100644 index 0000000..adce51c --- /dev/null +++ b/supabase/migrations/20260604200249_rename_bill_approvals_vendor_name_to_approver_name.sql @@ -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.'; diff --git a/supabase/migrations/20260604203329_bill_approval_email_tokens_v2.sql b/supabase/migrations/20260604203329_bill_approval_email_tokens_v2.sql new file mode 100644 index 0000000..3b631d8 --- /dev/null +++ b/supabase/migrations/20260604203329_bill_approval_email_tokens_v2.sql @@ -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;