mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
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:
@@ -0,0 +1,120 @@
|
||||
-- Per-bill, per-board-member secure approval tokens for email approve/deny.
|
||||
-- Recreated to use approver_name (post-rename) and populate approved_by/created_by
|
||||
-- from the linked board member's user_id (audit trail for who clicked the email link).
|
||||
-- The earlier 20260520153409 migration was never applied successfully; this is the
|
||||
-- corrected idempotent version.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.bill_approval_email_tokens (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bill_id uuid NOT NULL REFERENCES public.bills(id) ON DELETE CASCADE,
|
||||
board_member_id uuid NOT NULL REFERENCES public.board_members(id) ON DELETE CASCADE,
|
||||
token uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
|
||||
email text NOT NULL,
|
||||
member_name text,
|
||||
sent_at timestamptz,
|
||||
acted_at timestamptz,
|
||||
action text CHECK (action IN ('approved','denied')),
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (bill_id, board_member_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_baet_token ON public.bill_approval_email_tokens(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_baet_bill ON public.bill_approval_email_tokens(bill_id);
|
||||
|
||||
ALTER TABLE public.bill_approval_email_tokens ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS "Staff manage bill approval tokens" ON public.bill_approval_email_tokens;
|
||||
CREATE POLICY "Staff manage bill approval tokens" ON public.bill_approval_email_tokens
|
||||
TO authenticated
|
||||
USING (has_role(auth.uid(),'admin') OR has_role(auth.uid(),'manager'))
|
||||
WITH CHECK (has_role(auth.uid(),'admin') OR has_role(auth.uid(),'manager'));
|
||||
|
||||
-- Lookup RPC for the public page (anonymous-friendly, security definer).
|
||||
-- Note: the "vendor_name" returned here is the bill's vendor (from public.vendors),
|
||||
-- NOT the approver. That naming is for the public landing page UI.
|
||||
CREATE OR REPLACE FUNCTION public.lookup_bill_approval_by_token(p_token uuid)
|
||||
RETURNS TABLE (
|
||||
bill_id uuid,
|
||||
member_name text,
|
||||
member_email text,
|
||||
acted_at timestamptz,
|
||||
action text,
|
||||
vendor_name text,
|
||||
invoice_number text,
|
||||
bill_date date,
|
||||
due_date date,
|
||||
amount numeric,
|
||||
description text,
|
||||
attachment_url text,
|
||||
bill_status text,
|
||||
association_name text
|
||||
)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
SELECT t.bill_id, t.member_name, t.email AS member_email, t.acted_at, t.action,
|
||||
v.name AS vendor_name, b.invoice_number, b.bill_date, b.due_date, b.amount,
|
||||
b.description, b.attachment_url, b.status AS bill_status, a.name AS association_name
|
||||
FROM bill_approval_email_tokens t
|
||||
JOIN bills b ON b.id = t.bill_id
|
||||
LEFT JOIN vendors v ON v.id = b.vendor_id
|
||||
LEFT JOIN associations a ON a.id = b.association_id
|
||||
WHERE t.token = p_token;
|
||||
$$;
|
||||
GRANT EXECUTE ON FUNCTION public.lookup_bill_approval_by_token(uuid) TO anon, authenticated;
|
||||
|
||||
-- Record action via token. Anonymous callers identify via the secret token;
|
||||
-- the linked board_member.user_id (if any) is the audit identity we record.
|
||||
CREATE OR REPLACE FUNCTION public.record_bill_approval_by_token(
|
||||
p_token uuid,
|
||||
p_action text,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
tok bill_approval_email_tokens%ROWTYPE;
|
||||
bill bills%ROWTYPE;
|
||||
v_user_id uuid;
|
||||
BEGIN
|
||||
IF p_action NOT IN ('approved','denied') THEN
|
||||
RETURN jsonb_build_object('ok', false, 'error', 'Invalid action');
|
||||
END IF;
|
||||
|
||||
SELECT * INTO tok FROM bill_approval_email_tokens WHERE token = p_token;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'error', 'Invalid token');
|
||||
END IF;
|
||||
IF tok.acted_at IS NOT NULL THEN
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false, 'already_voted', true,
|
||||
'error', 'You have already responded', 'action', tok.action
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT * INTO bill FROM bills WHERE id = tok.bill_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('ok', false, 'error', 'Bill not found');
|
||||
END IF;
|
||||
|
||||
SELECT user_id INTO v_user_id FROM board_members WHERE id = tok.board_member_id;
|
||||
|
||||
INSERT INTO bill_approvals (
|
||||
bill_id, association_id, approver_name, amount,
|
||||
status, notes, approved_date, approved_by, created_by
|
||||
) VALUES (
|
||||
tok.bill_id, bill.association_id, COALESCE(tok.member_name, 'Board Member'), bill.amount,
|
||||
p_action, p_notes, CURRENT_DATE, v_user_id, v_user_id
|
||||
);
|
||||
|
||||
-- Note: the recompute_bill_status_from_approvals trigger on bill_approvals
|
||||
-- will derive public.bills.status from the votes (denied / approved when
|
||||
-- threshold met). We do NOT force-update bills.status here.
|
||||
|
||||
UPDATE bill_approval_email_tokens
|
||||
SET acted_at = now(), action = p_action, notes = p_notes
|
||||
WHERE token = p_token;
|
||||
|
||||
RETURN jsonb_build_object('ok', true, 'action', p_action);
|
||||
END;
|
||||
$$;
|
||||
GRANT EXECUTE ON FUNCTION public.record_bill_approval_by_token(uuid, text, text) TO anon, authenticated;
|
||||
Reference in New Issue
Block a user