import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend"; import { OrderConfirmation } from "@/emails/OrderConfirmation"; import { OrderShipped } from "@/emails/OrderShipped"; import { OrderCancelled } from "@/emails/OrderCancelled"; import { OrderPaid } from "@/emails/OrderPaid"; import { OpenPanel } from "@openpanel/nextjs"; const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com"; const DASHBOARD_URL = process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com"; // Initialize OpenPanel for server-side tracking const op = new OpenPanel({ clientId: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "", clientSecret: process.env.OPENPANEL_CLIENT_SECRET || "", apiUrl: process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api", }); interface SaleorWebhookHeaders { "saleor-event": string; "saleor-domain": string; "saleor-signature"?: string; "saleor-api-url": string; } // Saleor sends snake_case in webhook payloads interface SaleorLineItemPayload { id: string; product_name: string; variant_name?: string; quantity: number; total_price_gross_amount: string; currency: string; } interface SaleorAddressPayload { first_name?: string; last_name?: string; street_address_1?: string; street_address_2?: string; city?: string; postal_code?: string; country?: string; phone?: string; } interface SaleorOrderPayload { id: string; number: number; user_email: string; first_name?: string; last_name?: string; billing_address?: SaleorAddressPayload; shipping_address?: SaleorAddressPayload; lines: SaleorLineItemPayload[]; total_gross_amount: string; shipping_price_gross_amount?: string; channel: { currency_code: string; }; currency?: string; // Fallback for line items language_code?: string; metadata?: Record; } // Internal camelCase interfaces for our code interface SaleorLineItem { id: string; productName: string; variantName?: string; quantity: number; totalPrice: { gross: { amount: number; currency: string; }; }; } interface SaleorAddress { firstName?: string; lastName?: string; streetAddress1?: string; streetAddress2?: string; city?: string; postalCode?: string; country?: string; phone?: string; } interface SaleorOrder { id: string; number: string; userEmail: string; user?: { firstName?: string; lastName?: string; email?: string; }; billingAddress?: SaleorAddress; shippingAddress?: SaleorAddress; lines: SaleorLineItem[]; total: { gross: { amount: number; currency: string; }; }; shippingPrice?: { gross: { amount: number; currency: string; }; }; languageCode?: string; metadata?: Array<{ key: string; value: string }>; } const SUPPORTED_EVENTS = [ "ORDER_CREATED", "ORDER_CONFIRMED", "ORDER_FULLY_PAID", "ORDER_CANCELLED", "ORDER_FULFILLED", ]; const LANGUAGE_CODE_MAP: Record = { SR: "sr", EN: "en", DE: "de", FR: "fr", }; // Convert Saleor snake_case payload to camelCase function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder { return { id: payload.id, number: String(payload.number), userEmail: payload.user_email, user: payload.first_name || payload.last_name ? { firstName: payload.first_name, lastName: payload.last_name, email: payload.user_email, } : undefined, billingAddress: payload.billing_address ? { firstName: payload.billing_address.first_name, lastName: payload.billing_address.last_name, streetAddress1: payload.billing_address.street_address_1, streetAddress2: payload.billing_address.street_address_2, city: payload.billing_address.city, postalCode: payload.billing_address.postal_code, country: payload.billing_address.country, phone: payload.billing_address.phone, } : undefined, shippingAddress: payload.shipping_address ? { firstName: payload.shipping_address.first_name, lastName: payload.shipping_address.last_name, streetAddress1: payload.shipping_address.street_address_1, streetAddress2: payload.shipping_address.street_address_2, city: payload.shipping_address.city, postalCode: payload.shipping_address.postal_code, country: payload.shipping_address.country, phone: payload.shipping_address.phone, } : undefined, lines: payload.lines.map((line) => ({ id: line.id, productName: line.product_name, variantName: line.variant_name, quantity: line.quantity, totalPrice: { gross: { amount: parseInt(line.total_price_gross_amount), currency: line.currency || payload.channel.currency_code, }, }, })), total: { gross: { amount: parseInt(payload.total_gross_amount), currency: payload.channel.currency_code, }, }, shippingPrice: payload.shipping_price_gross_amount ? { gross: { amount: parseInt(payload.shipping_price_gross_amount), currency: payload.channel.currency_code, }, } : undefined, languageCode: payload.language_code?.toUpperCase(), metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined, }; } function getCustomerLanguage(order: SaleorOrder): string { if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) { return LANGUAGE_CODE_MAP[order.languageCode]; } if (order.metadata) { const langMeta = order.metadata.find((m) => m.key === "language"); if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) { return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]; } } return "en"; } function formatPrice(amount: number, currency: string): string { return new Intl.NumberFormat("sr-RS", { style: "currency", currency: currency, }).format(amount); } function formatAddress(address?: SaleorAddress): string { if (!address) return ""; const parts = [ address.firstName, address.lastName, address.streetAddress1, address.streetAddress2, address.postalCode, address.city, address.country, ].filter(Boolean); return parts.join(", "); } function getCustomerName(order: SaleorOrder): string { if (order.user?.firstName) { return `${order.user.firstName}${order.user.lastName ? ` ${order.user.lastName}` : ""}`; } if (order.billingAddress?.firstName) { return `${order.billingAddress.firstName}${order.billingAddress.lastName ? ` ${order.billingAddress.lastName}` : ""}`; } return "Customer"; } function parseOrderItems(lines: SaleorLineItem[], currency: string) { return lines.map((line) => ({ id: line.id, name: line.variantName ? `${line.productName} (${line.variantName})` : line.productName, quantity: line.quantity, price: formatPrice(line.totalPrice.gross.amount, currency), })); } async function handleOrderConfirmed(order: SaleorOrder, eventType: string) { const language = getCustomerLanguage(order); const currency = order.total.gross.currency; const customerName = getCustomerName(order); const customerEmail = order.userEmail; const phone = order.shippingAddress?.phone || order.billingAddress?.phone; // Only send customer email for ORDER_CONFIRMED, not ORDER_CREATED // This prevents duplicate emails when both events fire if (eventType === "ORDER_CONFIRMED") { await sendEmailToCustomer({ to: customerEmail, subject: language === "sr" ? `Potvrda narudžbine #${order.number}` : language === "de" ? `Bestellbestätigung #${order.number}` : language === "fr" ? `Confirmation de commande #${order.number}` : `Order Confirmation #${order.number}`, react: OrderConfirmation({ language, orderId: order.id, orderNumber: order.number, customerEmail, customerName, items: parseOrderItems(order.lines, currency), total: formatPrice(order.total.gross.amount, currency), shippingAddress: formatAddress(order.shippingAddress), siteUrl: SITE_URL, }), language, idempotencyKey: `order-confirmed/${order.id}`, }); } // Track order in OpenPanel op.track("order_received", { order_id: order.id, order_number: order.number, total: order.total.gross.amount, currency: order.total.gross.currency, item_count: order.lines.reduce((sum, line) => sum + line.quantity, 0), customer_email: customerEmail, event_type: eventType, }); // Track revenue op.track("revenue", { amount: order.total.gross.amount, currency: order.total.gross.currency, }); // Always send admin notification for both ORDER_CREATED and ORDER_CONFIRMED await sendEmailToAdmin({ subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`, react: OrderConfirmation({ language: "en", orderId: order.id, orderNumber: order.number, customerEmail, customerName, items: parseOrderItems(order.lines, currency), total: formatPrice(order.total.gross.amount, currency), shippingAddress: formatAddress(order.shippingAddress), billingAddress: formatAddress(order.billingAddress), phone, siteUrl: SITE_URL, dashboardUrl: DASHBOARD_URL, isAdmin: true, }), eventType: "ORDER_CONFIRMED", orderId: order.id, }); // Track order in OpenPanel op.track("order_received", { order_id: order.id, order_number: order.number, total: order.total.gross.amount, currency: order.total.gross.currency, item_count: order.lines.reduce((sum, line) => sum + line.quantity, 0), customer_email: customerEmail, event_type: eventType, }); // Track revenue using OpenPanel's revenue method op.revenue(order.total.gross.amount, { currency: order.total.gross.currency, order_id: order.id, order_number: order.number, }); } async function handleOrderFulfilled(order: SaleorOrder) { const language = getCustomerLanguage(order); const currency = order.total.gross.currency; const customerName = getCustomerName(order); const customerEmail = order.userEmail; let trackingNumber: string | undefined; let trackingUrl: string | undefined; if (order.metadata) { const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber"); if (trackingMeta) { trackingNumber = trackingMeta.value; } const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl"); if (trackingUrlMeta) { trackingUrl = trackingUrlMeta.value; } } await sendEmailToCustomer({ to: customerEmail, subject: language === "sr" ? `Vaša narudžbina #${order.number} je poslata!` : language === "de" ? `Ihre Bestellung #${order.number} wurde versendet!` : language === "fr" ? `Votre commande #${order.number} a été expédiée!` : `Your Order #${order.number} Has Shipped!`, react: OrderShipped({ language, orderId: order.id, orderNumber: order.number, customerName, items: parseOrderItems(order.lines, currency), trackingNumber, trackingUrl, siteUrl: SITE_URL, }), language, idempotencyKey: `order-fulfilled/${order.id}`, }); await sendEmailToAdmin({ subject: `Order Shipped #${order.number} - ${customerName}`, react: OrderShipped({ language: "en", orderId: order.id, orderNumber: order.number, customerName, items: parseOrderItems(order.lines, currency), trackingNumber, trackingUrl, siteUrl: SITE_URL, }), eventType: "ORDER_FULFILLED", orderId: order.id, }); } async function handleOrderCancelled(order: SaleorOrder) { const language = getCustomerLanguage(order); const currency = order.total.gross.currency; const customerName = getCustomerName(order); const customerEmail = order.userEmail; let reason: string | undefined; if (order.metadata) { const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason"); if (reasonMeta) { reason = reasonMeta.value; } } await sendEmailToCustomer({ to: customerEmail, subject: language === "sr" ? `Vaša narudžbina #${order.number} je otkazana` : language === "de" ? `Ihre Bestellung #${order.number} wurde storniert` : language === "fr" ? `Votre commande #${order.number} a été annulée` : `Your Order #${order.number} Has Been Cancelled`, react: OrderCancelled({ language, orderId: order.id, orderNumber: order.number, customerName, items: parseOrderItems(order.lines, currency), total: formatPrice(order.total.gross.amount, currency), reason, siteUrl: SITE_URL, }), language, idempotencyKey: `order-cancelled/${order.id}`, }); await sendEmailToAdmin({ subject: `Order Cancelled #${order.number} - ${customerName}`, react: OrderCancelled({ language: "en", orderId: order.id, orderNumber: order.number, customerName, items: parseOrderItems(order.lines, currency), total: formatPrice(order.total.gross.amount, currency), reason, siteUrl: SITE_URL, }), eventType: "ORDER_CANCELLED", orderId: order.id, }); } async function handleOrderFullyPaid(order: SaleorOrder) { const language = getCustomerLanguage(order); const currency = order.total.gross.currency; const customerName = getCustomerName(order); const customerEmail = order.userEmail; await sendEmailToCustomer({ to: customerEmail, subject: language === "sr" ? `Plaćanje za narudžbinu #${order.number} je primljeno!` : language === "de" ? `Zahlung für Bestellung #${order.number} erhalten!` : language === "fr" ? `Paiement reçu pour la commande #${order.number}!` : `Payment Received for Order #${order.number}!`, react: OrderPaid({ language, orderId: order.id, orderNumber: order.number, customerName, items: parseOrderItems(order.lines, currency), total: formatPrice(order.total.gross.amount, currency), siteUrl: SITE_URL, }), language, idempotencyKey: `order-paid/${order.id}`, }); await sendEmailToAdmin({ subject: `Payment Received #${order.number} - ${customerName} - ${formatPrice(order.total.gross.amount, currency)}`, react: OrderPaid({ language: "en", orderId: order.id, orderNumber: order.number, customerName, items: parseOrderItems(order.lines, currency), total: formatPrice(order.total.gross.amount, currency), siteUrl: SITE_URL, }), eventType: "ORDER_FULLY_PAID", orderId: order.id, }); } async function handleSaleorWebhook( event: string, payload: { order: SaleorOrder } ) { const { order } = payload; console.log(`Processing webhook event: ${event} for order ${order?.id}`); if (!order || !order.id) { console.error("No order in payload"); throw new Error("No order in payload"); } switch (event) { case "ORDER_CREATED": case "ORDER_CONFIRMED": await handleOrderConfirmed(order, event); break; case "ORDER_FULFILLED": await handleOrderFulfilled(order); break; case "ORDER_CANCELLED": await handleOrderCancelled(order); break; case "ORDER_FULLY_PAID": await handleOrderFullyPaid(order); break; default: console.log(`Unsupported event: ${event}`); } } export async function POST(request: NextRequest) { try { console.log("=== WEBHOOK RECEIVED ==="); console.log("Timestamp:", new Date().toISOString()); const body = await request.json(); const headers = request.headers; const event = headers.get("saleor-event") as string; const domain = headers.get("saleor-domain"); const signature = headers.get("saleor-signature"); const apiUrl = headers.get("saleor-api-url"); console.log(`Received webhook: ${event} from ${domain}`); console.log("Headers:", { event, domain, apiUrl, hasSignature: !!signature }); console.log("Payload:", JSON.stringify(body).substring(0, 500)); // Handle Saleor legacy webhook payload format (array with snake_case fields) let orderPayload: SaleorOrderPayload | null = null; if (Array.isArray(body) && body.length > 0) { // Legacy format: array with order objects directly orderPayload = body[0] as SaleorOrderPayload; } else if (body.data && Array.isArray(body.data)) { // Subscription format: { data: [...] } orderPayload = body.data[0] as SaleorOrderPayload; } if (!orderPayload) { console.error("No order found in webhook payload"); return NextResponse.json({ error: "No order in payload" }, { status: 400 }); } console.log("Order ID:", orderPayload.id); console.log("Order number:", orderPayload.number); console.log("User email:", orderPayload.user_email); if (!event) { return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 }); } // Normalize event to uppercase for comparison const normalizedEvent = event.toUpperCase(); if (!SUPPORTED_EVENTS.includes(normalizedEvent)) { console.log(`Event ${event} (normalized: ${normalizedEvent}) not supported, skipping`); return NextResponse.json({ success: true, message: "Event not supported" }); } // Convert snake_case payload to camelCase const order = convertPayloadToOrder(orderPayload); await handleSaleorWebhook(normalizedEvent, { order }); return NextResponse.json({ success: true }); } catch (error) { console.error("Webhook processing error:", error); return NextResponse.json( { error: "Internal server error", details: String(error) }, { status: 500 } ); } } export async function GET() { return NextResponse.json({ status: "ok", message: "Saleor webhook endpoint is active", supportedEvents: SUPPORTED_EVENTS, }); }