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,199 @@
|
||||
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 LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY");
|
||||
if (!LOVABLE_API_KEY) throw new Error("LOVABLE_API_KEY is not configured");
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Validate user from auth header
|
||||
const authHeader = req.headers.get("authorization") ?? "";
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const userClient = createClient(supabaseUrl, anonKey, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
const { data: { user }, error: authErr } = await userClient.auth.getUser();
|
||||
if (authErr || !user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const { question, association_id, chat_id } = await req.json();
|
||||
if (!question || !association_id) {
|
||||
return new Response(JSON.stringify({ error: "question and association_id are required" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch FAQs for the association
|
||||
const { data: faqs } = await supabase
|
||||
.from("association_faqs")
|
||||
.select("question, answer")
|
||||
.eq("association_id", association_id)
|
||||
.order("sort_order");
|
||||
|
||||
const faqList = (faqs || [])
|
||||
.filter((f: any) => f.answer)
|
||||
.map((f: any, i: number) => `${i + 1}. Q: ${f.question}\n A: ${f.answer}`)
|
||||
.join("\n\n");
|
||||
|
||||
const systemPrompt = `You are a friendly and helpful customer support assistant for a homeowners association community.
|
||||
|
||||
You have a knowledge base of pre-determined questions and answers. Try to match the homeowner's question to one of these FAQs and provide the answer. You can rephrase answers naturally but keep the same information.
|
||||
|
||||
If the question is clearly covered by one or more FAQs, answer it using that information.
|
||||
If the question is NOT covered by any FAQ, or you're unsure, respond with EXACTLY this JSON format:
|
||||
{"escalate": true, "reason": "Brief summary of what the homeowner is asking about"}
|
||||
|
||||
Do NOT make up answers. Only answer from the provided FAQ knowledge base.
|
||||
|
||||
Here are the available FAQs:
|
||||
${faqList || "No FAQs have been configured yet."}`;
|
||||
|
||||
// Call Lovable AI
|
||||
const aiResponse = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${LOVABLE_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: question },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!aiResponse.ok) {
|
||||
if (aiResponse.status === 429) {
|
||||
return new Response(JSON.stringify({ error: "Rate limited, please try again later." }), {
|
||||
status: 429,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (aiResponse.status === 402) {
|
||||
return new Response(JSON.stringify({ error: "Service temporarily unavailable." }), {
|
||||
status: 402,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const errText = await aiResponse.text();
|
||||
console.error("AI gateway error:", aiResponse.status, errText);
|
||||
throw new Error("AI gateway error");
|
||||
}
|
||||
|
||||
const aiData = await aiResponse.json();
|
||||
const aiMessage = aiData.choices?.[0]?.message?.content || "";
|
||||
|
||||
// Check if the AI wants to escalate
|
||||
let escalated = false;
|
||||
let answer = aiMessage;
|
||||
let escalateReason = "";
|
||||
|
||||
try {
|
||||
// Try parsing as JSON escalation response
|
||||
const cleaned = aiMessage.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (parsed.escalate === true) {
|
||||
escalated = true;
|
||||
escalateReason = parsed.reason || question;
|
||||
answer = "I wasn't able to find an answer to your question in our knowledge base. I've forwarded your question to our management team, and someone will get back to you shortly.";
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — it's a normal answer
|
||||
}
|
||||
|
||||
// Ensure/create a chat record
|
||||
let activeChatId = chat_id;
|
||||
if (!activeChatId) {
|
||||
const { data: newChat } = await supabase
|
||||
.from("support_chats")
|
||||
.insert({ user_id: user.id, association_id, status: "open" })
|
||||
.select("id")
|
||||
.single();
|
||||
activeChatId = newChat?.id;
|
||||
}
|
||||
|
||||
if (activeChatId) {
|
||||
// Save user message
|
||||
await supabase.from("support_chat_messages").insert({
|
||||
chat_id: activeChatId,
|
||||
role: "user",
|
||||
content: question,
|
||||
});
|
||||
// Save AI response
|
||||
await supabase.from("support_chat_messages").insert({
|
||||
chat_id: activeChatId,
|
||||
role: "assistant",
|
||||
content: answer,
|
||||
escalated,
|
||||
});
|
||||
}
|
||||
|
||||
// If escalated, notify all admins
|
||||
if (escalated) {
|
||||
// Get user's name
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("full_name")
|
||||
.eq("user_id", user.id)
|
||||
.single();
|
||||
const userName = profile?.full_name || user.email || "A homeowner";
|
||||
|
||||
// Get all admin user IDs
|
||||
const { data: adminRoles } = await supabase
|
||||
.from("user_roles")
|
||||
.select("user_id")
|
||||
.eq("role", "admin");
|
||||
|
||||
const adminIds = (adminRoles || []).map((r: any) => r.user_id);
|
||||
|
||||
// Send notification to each admin
|
||||
for (const adminId of adminIds) {
|
||||
await supabase.rpc("insert_notification", {
|
||||
p_user_id: adminId,
|
||||
p_type: "support",
|
||||
p_title: "Support Request",
|
||||
p_message: `${userName} asked: "${question.substring(0, 100)}${question.length > 100 ? "..." : ""}"`,
|
||||
});
|
||||
}
|
||||
|
||||
// Also send a direct message to each admin
|
||||
for (const adminId of adminIds) {
|
||||
await supabase.from("direct_messages").insert({
|
||||
sender_id: user.id,
|
||||
recipient_id: adminId,
|
||||
message: `[Support Request] ${userName} asked a question that couldn't be answered by the AI assistant:\n\n"${question}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ answer, escalated, chat_id: activeChatId }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("ai-support-chat error:", e);
|
||||
return new Response(
|
||||
JSON.stringify({ error: e instanceof Error ? e.message : "Unknown error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user