This commit is contained in:
gpt-engineer-app[bot]
2025-12-19 14:13:40 +00:00
parent 46caf550a6
commit e5d42d2d1b
7 changed files with 986 additions and 119 deletions

View File

@@ -1,22 +1,17 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
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",
};
// TODO: Set these in your Supabase Edge Function secrets
// 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")!;
// Email template placeholder - will be replaced with real provider later
const ORDER_PAID_EMAIL_TEMPLATE = Deno.env.get("ORDER_PAID_EMAIL_TEMPLATE") || JSON.stringify({
subject: "Pembayaran Berhasil - {{order_id}}",
body: "Terima kasih! Pembayaran untuk pesanan {{order_id}} sebesar Rp {{amount}} telah berhasil. Anda sekarang memiliki akses ke: {{products}}."
});
interface PakasirWebhookPayload {
amount: number;
order_id: string;
@@ -26,33 +21,201 @@ interface PakasirWebhookPayload {
completed_at?: string;
}
// Placeholder email function - logs for now, will be replaced with real provider
async function sendOrderPaidEmail(
userEmail: string,
order: { id: string; total_amount: number },
products: string[]
): Promise<void> {
try {
const template = JSON.parse(ORDER_PAID_EMAIL_TEMPLATE);
const subject = template.subject
.replace("{{order_id}}", order.id.substring(0, 8))
.replace("{{amount}}", order.total_amount.toLocaleString("id-ID"));
const body = template.body
.replace("{{order_id}}", order.id.substring(0, 8))
.replace("{{amount}}", order.total_amount.toLocaleString("id-ID"))
.replace("{{products}}", products.join(", "));
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;
}
console.log("[EMAIL] Would send to:", userEmail);
console.log("[EMAIL] Subject:", subject);
console.log("[EMAIL] Body:", body);
// TODO: Replace with actual email provider call (e.g., Resend, SendGrid)
// await emailProvider.send({ to: userEmail, subject, body });
} catch (error) {
console.error("[EMAIL] Error preparing email:", error);
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, string>): 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<boolean> {
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<string, unknown>): Promise<boolean> {
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<string, string>,
webhookPayload: Record<string, unknown>
): Promise<void> {
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 = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #f8f9fa; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #ffffff; padding: 30px; border: 1px solid #e9ecef; }
.footer { background: #f8f9fa; padding: 15px; text-align: center; font-size: 12px; color: #6c757d; border-radius: 0 0 8px 8px; }
a { color: #0066cc; }
.button { display: inline-block; padding: 12px 24px; background: #0066cc; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; }
</style>
</head>
<body>
<div class="container">
<div class="content">
${body}
</div>
<div class="footer">
Email ini dikirim otomatis. Jangan membalas email ini.
</div>
</div>
</body>
</html>`;
await sendEmail(
smtpSettings,
recipientEmail,
subject,
fullHtml,
brandFromName
);
}
serve(async (req) => {
@@ -143,64 +306,152 @@ serve(async (req) => {
console.log("[WEBHOOK] Order updated to paid:", order.id);
// Get order items to 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) {
const productId = item.product_id;
// Supabase joins can return array or single object depending on relationship
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);
}
}
}
// Get user email for notification
// Get user profile
const { data: profile } = await supabase
.from("profiles")
.select("email")
.select("full_name, email")
.eq("id", order.user_id)
.single();
// Send email notification (placeholder)
if (profile?.email) {
await sendOrderPaidEmail(profile.email, order, productTitles);
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);
}
// Format consulting slot details for notification
const slots = consultingSlots as Array<{ date: string; start_time: string; end_time: string; meet_link?: string }>;
const shortcodeData: Record<string, string> = {
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<string, unknown> = {
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<string, string> = {
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<string, unknown> = {
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<string, unknown> = {
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 }), {