diff --git a/src/components/BillApprovalRequestDialog.jsx b/src/components/BillApprovalRequestDialog.jsx index a591a3c..2ed53bb 100644 --- a/src/components/BillApprovalRequestDialog.jsx +++ b/src/components/BillApprovalRequestDialog.jsx @@ -60,6 +60,9 @@ 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 { @@ -68,6 +71,7 @@ export default function BillApprovalRequestDialog({ open, onOpenChange, billId, 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/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index bfda03c..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 - approver_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 - approver_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 - approver_name?: string } Relationships: [ { diff --git a/src/pages/AIInvoiceParserPage.tsx b/src/pages/AIInvoiceParserPage.tsx index fc2ad81..13556bf 100644 --- a/src/pages/AIInvoiceParserPage.tsx +++ b/src/pages/AIInvoiceParserPage.tsx @@ -375,6 +375,7 @@ export default function AIInvoiceParserPage() { 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/BillDetailPage.tsx b/src/pages/BillDetailPage.tsx index 88a07b4..dbca1de 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; @@ -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.approver_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.approver_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.
) : ( diff --git a/src/pages/master-board/MasterBoardDashboardPage.tsx b/src/pages/master-board/MasterBoardDashboardPage.tsx index 7fe058d..6b696ac 100644 --- a/src/pages/master-board/MasterBoardDashboardPage.tsx +++ b/src/pages/master-board/MasterBoardDashboardPage.tsx @@ -52,26 +52,36 @@ export default function MasterBoardDashboardPage() { .then(({ data }) => setAnnouncements(data || [])); }, [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(() => { if (!user || !associationIds.length) { setPendingBillCount(0); return; } (async () => { - const billsQ: any = supabase - .from("bills") - .select("id") - .in("association_id", associationIds) - .in("status", ["pending", "pending_approval", "submitted"]); - const { data: bills } = await billsQ; + const [{ data: bills }, { data: myMemberships }] = await Promise.all([ + supabase + .from("bills") + .select("id") + .in("association_id", associationIds) + .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); - if (!billIds.length) { setPendingBillCount(0); return; } - const client: any = supabase; - const q = client + const myNames = Array.from( + new Set((myMemberships || []).map((m: any) => m.member_name).filter(Boolean)) + ); + if (!billIds.length || !myNames.length) { setPendingBillCount(0); return; } + const { count } = await supabase .from("bill_approvals") .select("id", { count: "exact", head: true }) .in("bill_id", billIds) - .eq("approver_user_id", user.id) + .in("approver_name", myNames) .eq("status", "pending"); - const { count } = await q; setPendingBillCount(count ?? 0); })(); }, [user, associationIds]); diff --git a/supabase/functions/_shared/transactional-email-templates/bill-approval-request.tsx b/supabase/functions/_shared/transactional-email-templates/bill-approval-request.tsx new file mode 100644 index 0000000..2ba4c60 --- /dev/null +++ b/supabase/functions/_shared/transactional-email-templates/bill-approval-request.tsx @@ -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) => ( + + + Bills awaiting your approval{associationName ? ` — ${associationName}` : ''} + + +
+ Bills awaiting your approval + Hello {recipientName || 'Board Member'}, + + 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. + + {link && } + + 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/functions/send-bill-approval-invites/index.ts b/supabase/functions/send-bill-approval-invites/index.ts index 1d8eab6..788bcff 100644 --- a/supabase/functions/send-bill-approval-invites/index.ts +++ b/supabase/functions/send-bill-approval-invites/index.ts @@ -118,6 +118,10 @@ serve(async (req) => { const denyLink = `${reviewLink}&action=deny`; 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: { templateName: "bill-approval-vote-invite", recipientEmail: m.member_email, 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;