mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
153 lines
6.4 KiB
TypeScript
153 lines
6.4 KiB
TypeScript
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" },
|
|
});
|
|
}
|
|
});
|