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
@@ -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;