import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { createClient, SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.49.1"; import { SMTPClient } from "https://deno.land/x/denomailer@1.6.0/mod.ts"; const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-pakasir-signature, x-callback-token", }; // Environment variables const PAKASIR_WEBHOOK_SECRET = Deno.env.get("PAKASIR_WEBHOOK_SECRET") || ""; const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; interface PakasirWebhookPayload { amount: number; order_id: string; project: string; status: string; payment_method?: string; completed_at?: string; } interface SmtpSettings { smtp_host: string; smtp_port: number; smtp_username: string; smtp_password: string; smtp_from_name: string; smtp_from_email: string; smtp_use_tls: boolean; } interface NotificationTemplate { id: string; key: string; name: string; is_active: boolean; email_subject: string; email_body_html: string; webhook_url: string; } // Replace shortcodes in template function replaceShortcodes(template: string, data: Record): string { let result = template || ""; for (const [key, value] of Object.entries(data)) { result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), value || ''); } return result; } // Send email via SMTP async function sendEmail( smtp: SmtpSettings, to: string, subject: string, htmlBody: string, brandFromName?: string ): Promise { if (!smtp.smtp_host || !smtp.smtp_username || !smtp.smtp_password) { console.log("[EMAIL] SMTP not configured, skipping email"); return false; } try { const client = new SMTPClient({ connection: { hostname: smtp.smtp_host, port: smtp.smtp_port, tls: smtp.smtp_use_tls, auth: { username: smtp.smtp_username, password: smtp.smtp_password, }, }, }); const fromName = smtp.smtp_from_name || brandFromName || "Notification"; const fromEmail = smtp.smtp_from_email || smtp.smtp_username; await client.send({ from: `${fromName} <${fromEmail}>`, to: to, subject: subject, content: "auto", html: htmlBody, }); await client.close(); console.log("[EMAIL] Sent successfully to:", to); return true; } catch (error) { console.error("[EMAIL] Failed to send:", error); return false; } } // Send webhook notification async function sendWebhook(url: string, payload: Record): Promise { if (!url) return false; try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); console.log("[WEBHOOK] Sent to:", url, "Status:", response.status); return response.ok; } catch (error) { console.error("[WEBHOOK] Failed to send to:", url, error); return false; } } // Process notification for a specific event async function processNotification( supabase: SupabaseClient, templateKey: string, recipientEmail: string, shortcodeData: Record, webhookPayload: Record ): Promise { console.log(`[NOTIFICATION] Processing ${templateKey} for ${recipientEmail}`); // Fetch template const { data: templateData } = await supabase .from("notification_templates") .select("*") .eq("key", templateKey) .single(); if (!templateData) { console.log(`[NOTIFICATION] Template ${templateKey} not found`); return; } const template = templateData as NotificationTemplate; // ALWAYS send webhook if URL is configured (regardless of is_active) if (template.webhook_url) { await sendWebhook(template.webhook_url, webhookPayload); // Update last_payload_example await supabase .from("notification_templates") .update({ last_payload_example: webhookPayload, updated_at: new Date().toISOString() }) .eq("id", template.id); } // Only send email if is_active is true if (!template.is_active) { console.log(`[NOTIFICATION] Template ${templateKey} is not active, skipping email`); return; } // Fetch SMTP settings const { data: smtpData } = await supabase .from("notification_settings") .select("*") .single(); if (!smtpData) { console.log("[NOTIFICATION] SMTP settings not configured"); return; } const smtpSettings = smtpData as SmtpSettings; // Fetch brand settings for fallback from_name const { data: platformData } = await supabase .from("platform_settings") .select("brand_email_from_name") .single(); const brandFromName = (platformData as { brand_email_from_name?: string } | null)?.brand_email_from_name; // Replace shortcodes const subject = replaceShortcodes(template.email_subject, shortcodeData); const body = replaceShortcodes(template.email_body_html, shortcodeData); // Wrap body in email template const fullHtml = `
${body}
`; await sendEmail( smtpSettings, recipientEmail, subject, fullHtml, brandFromName ); } serve(async (req) => { // Handle CORS preflight if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); } try { // Verify webhook signature if configured const signature = req.headers.get("x-pakasir-signature") || req.headers.get("x-callback-token") || ""; if (PAKASIR_WEBHOOK_SECRET && signature !== PAKASIR_WEBHOOK_SECRET) { console.error("[WEBHOOK] Invalid signature"); return new Response(JSON.stringify({ error: "Invalid signature" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const payload: PakasirWebhookPayload = await req.json(); console.log("[WEBHOOK] Received payload:", JSON.stringify(payload)); // Validate required fields if (!payload.order_id || !payload.status) { console.error("[WEBHOOK] Missing required fields"); return new Response(JSON.stringify({ error: "Missing required fields" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } // Only process completed payments if (payload.status !== "completed") { console.log("[WEBHOOK] Ignoring non-completed status:", payload.status); return new Response(JSON.stringify({ message: "Status not completed, ignored" }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } // Create Supabase client with service role for admin access const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); // Find the order by payment_reference or id const { data: order, error: orderError } = await supabase .from("orders") .select("id, user_id, total_amount, payment_status") .or(`payment_reference.eq.${payload.order_id},id.eq.${payload.order_id}`) .single(); if (orderError || !order) { console.error("[WEBHOOK] Order not found:", payload.order_id, orderError); return new Response(JSON.stringify({ error: "Order not found" }), { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } // Skip if already paid if (order.payment_status === "paid") { console.log("[WEBHOOK] Order already paid:", order.id); return new Response(JSON.stringify({ message: "Order already paid" }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } // Update order status const { error: updateError } = await supabase .from("orders") .update({ payment_status: "paid", status: "paid", payment_provider: "pakasir", payment_method: payload.payment_method || "unknown", updated_at: new Date().toISOString(), }) .eq("id", order.id); if (updateError) { console.error("[WEBHOOK] Failed to update order:", updateError); return new Response(JSON.stringify({ error: "Failed to update order" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } console.log("[WEBHOOK] Order updated to paid:", order.id); // Get user profile const { data: profile } = await supabase .from("profiles") .select("full_name, email") .eq("id", order.user_id) .single(); const userEmail = (profile as { email?: string } | null)?.email || ""; const userName = (profile as { full_name?: string } | null)?.full_name || "Pelanggan"; // Check for consulting slots linked to this order const { data: consultingSlots } = await supabase .from("consulting_slots") .select("*") .eq("order_id", order.id); if (consultingSlots && consultingSlots.length > 0) { // This is a consulting order - update slot statuses const { error: slotUpdateError } = await supabase .from("consulting_slots") .update({ status: "confirmed" }) .eq("order_id", order.id); if (slotUpdateError) { console.error("[WEBHOOK] Failed to update consulting slots:", slotUpdateError); } else { console.log("[WEBHOOK] Consulting slots confirmed for order:", order.id); } // Create Google Meet events for each consulting slot for (const slot of consultingSlots) { try { console.log("[WEBHOOK] Creating Google Meet event for slot:", slot.id); const { data: settings } = await supabase .from("platform_settings") .select("integration_google_calendar_id") .single(); if (!settings?.integration_google_calendar_id) { console.log("[WEBHOOK] Google Calendar not configured, skipping Meet creation"); continue; } // Get product info for the topic const { data: orderItems } = await supabase .from("order_items") .select("product_id, products(name)") .eq("order_id", order.id) .limit(1); const topic = orderItems?.[0]?.products?.name || "Konsultasi 1-on-1"; const meetResponse = await fetch( `${Deno.env.get("SUPABASE_URL")}/functions/v1/create-google-meet-event`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`, }, body: JSON.stringify({ slot_id: slot.id, date: slot.date, start_time: slot.start_time, end_time: slot.end_time, client_name: userName, client_email: userEmail, topic: topic, }), } ); if (meetResponse.ok) { const meetData = await meetResponse.json(); if (meetData.success) { console.log("[WEBHOOK] Google Meet event created:", meetData.meet_link); } else { console.error("[WEBHOOK] Failed to create Meet event:", meetData.message); } } else { console.error("[WEBHOOK] Meet creation API error:", meetResponse.status); } } catch (error) { console.error("[WEBHOOK] Error creating Google Meet event:", error); // Don't fail the webhook if Meet creation fails } } // Refresh slots to get updated meet_link const { data: updatedSlots } = await supabase .from("consulting_slots") .select("*") .eq("order_id", order.id); // Format consulting slot details for notification const slots = (updatedSlots || consultingSlots) as Array<{ date: string; start_time: string; end_time: string; meet_link?: string }>; const shortcodeData: Record = { nama: userName, email: userEmail, order_id: order.id.substring(0, 8), tanggal_pesanan: new Date().toLocaleDateString("id-ID"), total: `Rp ${order.total_amount.toLocaleString("id-ID")}`, metode_pembayaran: payload.payment_method || "Pakasir", tanggal_konsultasi: slots[0]?.date || "", jam_konsultasi: slots.map(s => s.start_time.substring(0, 5)).join(", "), link_meet: slots[0]?.meet_link || "Akan dikirim terpisah", }; const webhookPayload: Record = { event: "consulting_scheduled", order_id: order.id, user_id: order.user_id, user_email: userEmail, user_name: userName, total_amount: order.total_amount, payment_method: payload.payment_method, slots: consultingSlots, timestamp: new Date().toISOString(), }; // Send consulting_scheduled notification await processNotification(supabase, "consulting_scheduled", userEmail, shortcodeData, webhookPayload); } else { // Regular product order - grant access const { data: orderItems, error: itemsError } = await supabase .from("order_items") .select("product_id, product:products(title)") .eq("order_id", order.id); if (itemsError) { console.error("[WEBHOOK] Failed to fetch order items:", itemsError); } const productTitles: string[] = []; // Grant user_access for each product if (orderItems && orderItems.length > 0) { for (const item of orderItems as Array<{ product_id: string; product: { title: string } | { title: string }[] | null }>) { const productId = item.product_id; const productData = Array.isArray(item.product) ? item.product[0] : item.product; // Check if access already exists const { data: existingAccess } = await supabase .from("user_access") .select("id") .eq("user_id", order.user_id) .eq("product_id", productId) .maybeSingle(); if (!existingAccess) { const { error: accessError } = await supabase .from("user_access") .insert({ user_id: order.user_id, product_id: productId, }); if (accessError) { console.error("[WEBHOOK] Failed to grant access for product:", productId, accessError); } else { console.log("[WEBHOOK] Granted access for product:", productId); } } if (productData?.title) { productTitles.push(productData.title); } } } const shortcodeData: Record = { nama: userName, email: userEmail, order_id: order.id.substring(0, 8), tanggal_pesanan: new Date().toLocaleDateString("id-ID"), total: `Rp ${order.total_amount.toLocaleString("id-ID")}`, metode_pembayaran: payload.payment_method || "Pakasir", produk: productTitles.join(", "), link_akses: `${Deno.env.get("SITE_URL") || ""}/access`, }; const webhookPayload: Record = { event: "payment_success", order_id: order.id, user_id: order.user_id, user_email: userEmail, user_name: userName, total_amount: order.total_amount, payment_method: payload.payment_method, products: productTitles, timestamp: new Date().toISOString(), }; // Send payment_success notification await processNotification(supabase, "payment_success", userEmail, shortcodeData, webhookPayload); // Also send access_granted notification if (productTitles.length > 0) { const accessWebhookPayload: Record = { event: "access_granted", order_id: order.id, user_id: order.user_id, user_email: userEmail, user_name: userName, products: productTitles, timestamp: new Date().toISOString(), }; await processNotification(supabase, "access_granted", userEmail, shortcodeData, accessWebhookPayload); } } return new Response(JSON.stringify({ success: true, order_id: order.id }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } catch (error) { console.error("[WEBHOOK] Unexpected error:", error); return new Response(JSON.stringify({ error: "Internal server error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } });