mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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",
|
||||
};
|
||||
|
||||
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);
|
||||
// user-scoped client for verifying the caller
|
||||
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();
|
||||
const { board_vote_id, base_url, sender_id } = body || {};
|
||||
if (!board_vote_id || !base_url) {
|
||||
return new Response(JSON.stringify({ error: "board_vote_id and base_url are required" }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Verify caller is staff
|
||||
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" },
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch vote
|
||||
const { data: vote, error: vErr } = await admin
|
||||
.from("board_votes")
|
||||
.select("id, title, description, vote_options, status, association_id, associations(name)")
|
||||
.eq("id", board_vote_id)
|
||||
.single();
|
||||
if (vErr || !vote) {
|
||||
return new Response(JSON.stringify({ error: "Board vote not found" }), {
|
||||
status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (vote.status !== "open") {
|
||||
return new Response(JSON.stringify({ error: "Vote is not open" }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// (Sender resolution removed — emails now go through Lovable Cloud transactional email.)
|
||||
|
||||
// Fetch board members for this association
|
||||
const { data: members } = await admin
|
||||
.from("board_members")
|
||||
.select("id, member_name, member_email")
|
||||
.eq("association_id", vote.association_id);
|
||||
|
||||
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 addresses" }), {
|
||||
status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const sent: string[] = [];
|
||||
const failed: { email: string; reason: string }[] = [];
|
||||
const assocName = (vote as any).associations?.name || "Your Association";
|
||||
|
||||
for (const member of eligible) {
|
||||
// Upsert token per (vote, member)
|
||||
let token: string | null = null;
|
||||
const { data: existing } = await admin
|
||||
.from("board_vote_email_tokens")
|
||||
.select("token")
|
||||
.eq("board_vote_id", board_vote_id)
|
||||
.eq("board_member_id", member.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (existing?.token) {
|
||||
token = existing.token;
|
||||
await admin.from("board_vote_email_tokens").update({
|
||||
email: member.member_email,
|
||||
member_name: member.member_name,
|
||||
sent_at: new Date().toISOString(),
|
||||
}).eq("board_vote_id", board_vote_id).eq("board_member_id", member.id);
|
||||
} else {
|
||||
const { data: inserted, error: insErr } = await admin
|
||||
.from("board_vote_email_tokens")
|
||||
.insert({
|
||||
board_vote_id,
|
||||
board_member_id: member.id,
|
||||
email: member.member_email,
|
||||
member_name: member.member_name,
|
||||
sent_at: new Date().toISOString(),
|
||||
})
|
||||
.select("token")
|
||||
.single();
|
||||
if (insErr || !inserted) {
|
||||
failed.push({ email: member.member_email, reason: insErr?.message || "Could not create token" });
|
||||
continue;
|
||||
}
|
||||
token = inserted.token;
|
||||
}
|
||||
|
||||
const link = `${base_url.replace(/\/$/, "")}/board-vote/${board_vote_id}?token=${token}`;
|
||||
const { error: sendErr } = await admin.functions.invoke("send-transactional-email", {
|
||||
body: {
|
||||
templateName: "board-vote-invite",
|
||||
recipientEmail: member.member_email,
|
||||
idempotencyKey: `board-vote-${board_vote_id}-${member.id}`,
|
||||
templateData: {
|
||||
memberName: member.member_name || "Board Member",
|
||||
voteTitle: vote.title,
|
||||
voteDescription: vote.description || "",
|
||||
associationName: assocName,
|
||||
link,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (sendErr) {
|
||||
failed.push({ email: member.member_email, reason: sendErr.message || "Send failed" });
|
||||
} else {
|
||||
sent.push(member.member_email);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ sent: sent.length, failed: failed.length, details: { sent, failed } }), {
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("send-board-vote-invites error:", err);
|
||||
return new Response(JSON.stringify({ error: err.message || String(err) }), {
|
||||
status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
Reference in New Issue
Block a user