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) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 17:17:05 -04:00
parent bd5caf5415
commit 2c723410a4
18 changed files with 796 additions and 95 deletions
@@ -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;