import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version", }; const fmtMoney = (n: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(Number(n || 0)); serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); try { const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const authHeader = req.headers.get("Authorization") || ""; const admin = createClient(supabaseUrl, serviceKey); const userClient = createClient(supabaseUrl, Deno.env.get("SUPABASE_ANON_KEY")!, { global: { headers: { Authorization: authHeader } }, }); const { data: userData } = await userClient.auth.getUser(); const user = userData?.user; if (!user) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const body = await req.json().catch(() => ({})); const { association_id, bill_ids, base_url } = body || {}; if (!association_id || !base_url) { return new Response(JSON.stringify({ error: "association_id and base_url are required" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const { data: roles } = await admin.from("user_roles").select("role").eq("user_id", user.id); const isStaff = (roles || []).some((r: any) => r.role === "admin" || r.role === "manager"); if (!isStaff) { return new Response(JSON.stringify({ error: "Forbidden — staff only" }), { status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } // Pending bills for this association let billsQ = admin .from("bills") .select("id, vendor_id, invoice_number, bill_date, due_date, amount, description, status, association_id, vendors(name), associations(name)") .eq("association_id", association_id) .eq("status", "pending"); if (Array.isArray(bill_ids) && bill_ids.length > 0) billsQ = billsQ.in("id", bill_ids); const { data: bills, error: bErr } = await billsQ; if (bErr) throw bErr; if (!bills || bills.length === 0) { return new Response(JSON.stringify({ error: "No pending bills to send" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const { data: members } = await admin .from("board_members") .select("id, member_name, member_email, approval_authority") .eq("association_id", association_id) .eq("approval_authority", true); const eligible = (members || []).filter( (m: any) => m.member_email && /\S+@\S+\.\S+/.test(m.member_email) ); if (eligible.length === 0) { return new Response(JSON.stringify({ error: "No board members with email and approval authority" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const sent: string[] = []; const failed: { email: string; bill_id: string; reason: string }[] = []; for (const bill of bills) { for (const m of eligible) { // Upsert token let token: string | null = null; const { data: existing } = await admin .from("bill_approval_email_tokens") .select("token, acted_at") .eq("bill_id", bill.id) .eq("board_member_id", m.id) .maybeSingle(); if (existing?.token) { token = existing.token; await admin.from("bill_approval_email_tokens").update({ email: m.member_email, member_name: m.member_name, sent_at: new Date().toISOString(), }).eq("bill_id", bill.id).eq("board_member_id", m.id); } else { const { data: inserted, error: insErr } = await admin .from("bill_approval_email_tokens") .insert({ bill_id: bill.id, board_member_id: m.id, email: m.member_email, member_name: m.member_name, sent_at: new Date().toISOString(), }) .select("token") .single(); if (insErr || !inserted) { failed.push({ email: m.member_email, bill_id: bill.id, reason: insErr?.message || "token error" }); continue; } token = inserted.token; } const baseUrl = String(base_url).replace(/\/$/, ""); const reviewLink = `${baseUrl}/bill-approve/${bill.id}?token=${token}`; const approveLink = `${reviewLink}&action=approve`; const denyLink = `${reviewLink}&action=deny`; const { error: sendErr } = await admin.functions.invoke("send-transactional-email", { body: { templateName: "bill-approval-vote-invite", recipientEmail: m.member_email, idempotencyKey: `bill-approval-${bill.id}-${m.id}`, templateData: { memberName: m.member_name || "Board Member", associationName: (bill as any).associations?.name || "", vendorName: (bill as any).vendors?.name || "", invoiceNumber: bill.invoice_number || "", amount: fmtMoney(bill.amount), billDate: bill.bill_date || "", dueDate: bill.due_date || "", description: bill.description || "", approveLink, denyLink, reviewLink, }, }, }); if (sendErr) failed.push({ email: m.member_email, bill_id: bill.id, reason: sendErr.message || "send failed" }); else sent.push(m.member_email); } } return new Response(JSON.stringify({ sent: sent.length, failed: failed.length, bills: bills.length, members: eligible.length, details: { failed }, }), { headers: { ...corsHeaders, "Content-Type": "application/json" } }); } catch (err: any) { console.error("send-bill-approval-invites error:", err); return new Response(JSON.stringify({ error: err.message || String(err) }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } });