diff --git a/apps/backend/src/lib/notifications.ts b/apps/backend/src/lib/notifications.ts new file mode 100644 index 0000000..afdfb3d --- /dev/null +++ b/apps/backend/src/lib/notifications.ts @@ -0,0 +1,573 @@ +import prisma from "./prisma"; +import { lineClient } from "./lineClient"; + +export async function sendOrderCreatedNotification(orderId: number) { + try { + const order = await prisma.order.findUnique({ + where: { id: orderId }, + include: { items: true, customer: true } + }); + + if (!order || !order.customer?.lineUserId) { + console.log(`[Notification] Order #${orderId} has no associated lineUserId or does not exist.`); + return; + } + + const settings = await prisma.storeSettings.findFirst(); + const bankName = settings?.bankName || "KASIKORNBANK (KBANK)"; + const bankAccountNumber = settings?.bankAccountNumber || "012-3-45678-9"; + const bankAccountName = settings?.bankAccountName || "HomAroma Co., Ltd."; + + const itemContents = order.items.map(item => ({ + type: "box", + layout: "horizontal", + contents: [ + { + type: "text", + text: `${item.productName}${item.variantSize ? ` (${item.variantSize})` : ""} x ${item.quantity}`, + size: "sm", + color: "#333333", + flex: 3, + wrap: true + }, + { + type: "text", + text: `฿${item.priceAtPurchase * item.quantity}`, + size: "sm", + color: "#1C3F3A", + weight: "bold", + align: "end", + flex: 1 + } + ] + })); + + const flexMessage: any = { + type: "flex", + altText: `HomAroma: คำสั่งซื้อ #${order.id} ได้รับแล้ว กรุณาชำระเงิน`, + contents: { + type: "bubble", + header: { + type: "box", + layout: "vertical", + backgroundColor: "#1C3F3A", + contents: [ + { + type: "text", + text: "HomAroma Official", + color: "#D4B16A", + weight: "bold", + size: "xs" + }, + { + type: "text", + text: "ได้รับคำสั่งซื้อของคุณแล้ว", + color: "#FFFFFF", + weight: "bold", + size: "md", + margin: "sm" + } + ] + }, + body: { + type: "box", + layout: "vertical", + contents: [ + { + type: "text", + text: `หมายเลขออเดอร์: #HM-${order.id}`, + weight: "bold", + size: "sm", + color: "#333333" + }, + { + type: "separator", + margin: "md" + }, + { + type: "box", + layout: "vertical", + margin: "md", + spacing: "sm", + contents: [ + { + type: "box", + layout: "horizontal", + contents: [ + { + type: "text", + text: "รายการสินค้า", + size: "xs", + color: "#888888", + flex: 3 + }, + { + type: "text", + text: "ยอดรวม", + size: "xs", + color: "#888888", + align: "end", + flex: 1 + } + ] + }, + ...itemContents + ] + }, + { + type: "separator", + margin: "md" + }, + { + type: "box", + layout: "horizontal", + margin: "md", + contents: [ + { + type: "text", + text: "ยอดชำระสุทธิ (รวมค่าส่ง)", + size: "sm", + color: "#555555", + weight: "bold", + flex: 3 + }, + { + type: "text", + text: `฿${order.totalAmount}`, + size: "sm", + color: "#1C3F3A", + weight: "bold", + align: "end", + flex: 1 + } + ] + }, + { + type: "separator", + margin: "md" + }, + { + type: "box", + layout: "vertical", + margin: "md", + contents: [ + { + type: "text", + text: "🏦 ช่องทางการชำระเงินโอนเงิน:", + size: "xs", + color: "#555555", + weight: "bold" + }, + { + type: "text", + text: `${bankName} (${bankAccountNumber})`, + size: "xs", + color: "#1C3F3A", + margin: "xs", + weight: "bold" + }, + { + type: "text", + text: `ชื่อบัญชี: ${bankAccountName}`, + size: "xs", + color: "#555555" + } + ] + } + ] + }, + footer: { + type: "box", + layout: "vertical", + spacing: "sm", + contents: [ + { + type: "button", + style: "primary", + color: "#1C3F3A", + action: { + "type": "uri", + "label": "แนบสลิปชำระเงิน", + "uri": `https://spa.homaroma.shop/order-success?id=${order.id}` + } + }, + { + type: "button", + style: "link", + height: "sm", + action: { + "type": "uri", + "label": "ติดตามสถานะออเดอร์", + "uri": "https://spa.homaroma.shop/track-order" + } + } + ] + } + } + }; + + await lineClient.pushMessage({ + to: order.customer.lineUserId, + messages: [flexMessage] + }); + console.log(`[Notification] OrderCreated notification sent for Order #${orderId}`); + } catch (err) { + console.error(`[Notification] Failed to send OrderCreated notification for Order #${orderId}:`, err); + } +} + +export async function sendPaymentConfirmedNotification(orderId: number) { + try { + const order = await prisma.order.findUnique({ + where: { id: orderId }, + include: { customer: true } + }); + + if (!order || !order.customer?.lineUserId) { + console.log(`[Notification] Order #${orderId} has no associated lineUserId or does not exist.`); + return; + } + + const flexMessage: any = { + type: "flex", + altText: "HomAroma: ได้รับการชำระเงินเรียบร้อยแล้ว", + contents: { + type: "bubble", + body: { + type: "box", + layout: "vertical", + contents: [ + { + type: "box", + layout: "vertical", + alignItems: "center", + contents: [ + { + type: "text", + text: "✨ ชำระเงินสำเร็จ ✨", + weight: "bold", + size: "xl", + color: "#1C3F3A" + }, + { + type: "text", + text: `ขอขอบคุณสำหรับคำสั่งซื้อออเดอร์ #HM-${order.id}`, + size: "xs", + color: "#888888", + margin: "xs" + } + ] + }, + { + type: "box", + layout: "vertical", + backgroundColor: "#F4F6F5", + margin: "lg", + paddingAll: "md", + cornerRadius: "md", + contents: [ + { + type: "box", + layout: "horizontal", + contents: [ + { + type: "text", + text: "ได้รับแต้มครั้งนี้", + size: "sm", + color: "#555555" + }, + { + type: "text", + text: `+${order.pointsEarned} แต้ม`, + weight: "bold", + size: "sm", + color: "#1C3F3A", + align: "end" + } + ] + }, + { + type: "box", + layout: "horizontal", + margin: "xs", + contents: [ + { + type: "text", + text: "แต้มสะสมรวมล่าสุด", + size: "sm", + color: "#555555" + }, + { + type: "text", + text: `${order.customer.points} แต้ม`, + weight: "bold", + size: "sm", + color: "#D4B16A", + align: "end" + } + ] + } + ] + } + ] + }, + footer: { + type: "box", + layout: "vertical", + contents: [ + { + type: "button", + style: "primary", + color: "#1C3F3A", + action: { + "type": "uri", + "label": "แลกของรางวัล & คูปองสะสม", + "uri": "https://spa.homaroma.shop/profile" + } + } + ] + } + } + }; + + await lineClient.pushMessage({ + to: order.customer.lineUserId, + messages: [flexMessage] + }); + console.log(`[Notification] PaymentConfirmed notification sent for Order #${orderId}`); + } catch (err) { + console.error(`[Notification] Failed to send PaymentConfirmed notification for Order #${orderId}:`, err); + } +} + +export async function sendOrderShippedNotification(orderId: number) { + try { + const order = await prisma.order.findUnique({ + where: { id: orderId }, + include: { customer: true } + }); + + if (!order || !order.customer?.lineUserId) { + console.log(`[Notification] Order #${orderId} has no associated lineUserId or does not exist.`); + return; + } + + const flexMessage: any = { + type: "flex", + altText: "HomAroma: สินค้าของคุณอยู่ระหว่างจัดส่ง", + contents: { + type: "bubble", + header: { + type: "box", + layout: "vertical", + backgroundColor: "#1C3F3A", + contents: [ + { + type: "text", + text: "ออเดอร์ของคุณกำลังเดินทาง 🚚", + color: "#FFFFFF", + weight: "bold", + size: "md" + } + ] + }, + body: { + type: "box", + layout: "vertical", + spacing: "md", + contents: [ + { + type: "box", + layout: "horizontal", + contents: [ + { + type: "text", + text: "หมายเลขออเดอร์:", + size: "sm", + color: "#888888", + flex: 2 + }, + { + type: "text", + text: `#HM-${order.id}`, + size: "sm", + color: "#333333", + weight: "bold", + flex: 3 + } + ] + }, + { + type: "box", + layout: "horizontal", + contents: [ + { + type: "text", + text: "หมายเลขพัสดุ:", + size: "sm", + color: "#888888", + flex: 2 + }, + { + type: "text", + text: order.trackingNo || "อยู่ระหว่างเข้ารับพัสดุ", + size: "sm", + color: "#1C3F3A", + weight: "bold", + flex: 3 + } + ] + } + ] + }, + footer: { + type: "box", + layout: "vertical", + contents: [ + { + type: "button", + style: "primary", + color: "#1C3F3A", + action: { + "type": "uri", + "label": "เช็คสถานะการจัดส่ง", + "uri": "https://spa.homaroma.shop/track-order" + } + } + ] + } + } + }; + + await lineClient.pushMessage({ + to: order.customer.lineUserId, + messages: [flexMessage] + }); + console.log(`[Notification] OrderShipped notification sent for Order #${orderId}`); + } catch (err) { + console.error(`[Notification] Failed to send OrderShipped notification for Order #${orderId}:`, err); + } +} + +export async function sendOrderCancelledNotification(orderId: number) { + try { + const order = await prisma.order.findUnique({ + where: { id: orderId }, + include: { customer: true } + }); + + if (!order || !order.customer?.lineUserId) { + console.log(`[Notification] Order #${orderId} has no associated lineUserId or does not exist.`); + return; + } + + const flexMessage: any = { + type: "flex", + altText: "HomAroma: คำสั่งซื้อของคุณถูกยกเลิกแล้ว", + contents: { + type: "bubble", + header: { + type: "box", + layout: "vertical", + backgroundColor: "#8C2A2A", + contents: [ + { + type: "text", + text: "คำสั่งซื้อถูกยกเลิก 🚫", + color: "#FFFFFF", + weight: "bold", + size: "md" + } + ] + }, + body: { + type: "box", + layout: "vertical", + spacing: "md", + contents: [ + { + type: "text", + text: `คำสั่งซื้อหมายเลข #HM-${order.id} ได้ถูกยกเลิกแล้ว เนื่องจากเกินกำหนดเวลาชำระเงิน หรือสิทธิประโยชน์บางประการ หรือตามความประสงค์ของคุณ`, + size: "sm", + color: "#333333", + wrap: true + }, + ...(order.pointsUsed > 0 ? [ + { + type: "text", + text: `* ระบบได้ทำการคืนคะแนนสะสมจำนวน ${order.pointsUsed} แต้มเข้าบัญชีของคุณเรียบร้อยแล้ว`, + size: "xs", + color: "#8C2A2A", + weight: "bold", + margin: "md" + } + ] : []) + ] + }, + footer: { + type: "box", + layout: "vertical", + contents: [ + { + type: "button", + style: "primary", + color: "#1C3F3A", + action: { + "type": "uri", + "label": "ช้อปต่ออีกครั้ง", + "uri": "https://spa.homaroma.shop/" + } + } + ] + } + } + }; + + await lineClient.pushMessage({ + to: order.customer.lineUserId, + messages: [flexMessage] + }); + console.log(`[Notification] OrderCancelled notification sent for Order #${orderId}`); + } catch (err) { + console.error(`[Notification] Failed to send OrderCancelled notification for Order #${orderId}:`, err); + } +} + +export async function markOrderAsPaid(orderId: number) { + try { + const order = await prisma.order.findUnique({ + where: { id: orderId } + }); + + if (!order) { + console.log(`[Payment] Order #${orderId} not found in markOrderAsPaid.`); + return; + } + + if (order.status === "PAID") { + console.log(`[Payment] Order #${orderId} is already PAID.`); + return; + } + + // 1. Update status to PAID + await prisma.order.update({ + where: { id: orderId }, + data: { status: "PAID" } + }); + + // 2. Grant points + if (order.customerId && order.pointsEarned > 0) { + await prisma.customer.update({ + where: { id: order.customerId }, + data: { points: { increment: order.pointsEarned } } + }); + console.log(`[Payment] Granted ${order.pointsEarned} points to Customer #${order.customerId} for Order #${orderId}`); + } + + // 3. Send payment notification + await sendPaymentConfirmedNotification(orderId); + } catch (err) { + console.error(`[Payment] Error in markOrderAsPaid for Order #${orderId}:`, err); + } +} + diff --git a/apps/backend/src/routes/line.ts b/apps/backend/src/routes/line.ts index 6454638..b5b4ec8 100644 --- a/apps/backend/src/routes/line.ts +++ b/apps/backend/src/routes/line.ts @@ -37,7 +37,7 @@ const webhookHandler = async ({ request, body, set }: { request: Request, body: try { const parsedBody = typeof body === 'string' ? JSON.parse(body) : body; - const events: line.WebhookEvent[] = parsedBody.events || []; + const events: line.webhook.Event[] = parsedBody.events || []; for (const event of events) { if (event.type === 'message' && event.message.type === 'text') { @@ -48,7 +48,7 @@ const webhookHandler = async ({ request, body, set }: { request: Request, body: // Simple echo bot for now await lineClient.replyMessage({ - replyToken: event.replyToken, + replyToken: event.replyToken!, messages: [{ type: 'text', text: `คุณส่งข้อความว่า: ${event.message.text}` diff --git a/apps/backend/src/routes/orders.ts b/apps/backend/src/routes/orders.ts index 29c674e..f5dc57d 100644 --- a/apps/backend/src/routes/orders.ts +++ b/apps/backend/src/routes/orders.ts @@ -1,5 +1,12 @@ import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; +import { + sendOrderCreatedNotification, + sendPaymentConfirmedNotification, + sendOrderShippedNotification, + sendOrderCancelledNotification, + markOrderAsPaid +} from "../lib/notifications"; export const ordersRoute = new Elysia({ prefix: "/orders" }) .get("/", async () => { @@ -87,6 +94,11 @@ export const ordersRoute = new Elysia({ prefix: "/orders" }) } } + // Trigger LINE notification asynchronously + sendOrderCreatedNotification(order.id).catch(e => { + console.error("Failed to trigger sendOrderCreatedNotification:", e); + }); + return order; } catch (err) { console.error("[Orders] Error:", err); @@ -127,29 +139,40 @@ export const ordersRoute = new Elysia({ prefix: "/orders" }) const { status } = body as any; const order = await prisma.order.findUnique({ where: { id: Number(id) } }); - const updatedOrder = await prisma.order.update({ - where: { id: Number(id) }, - data: { status }, - include: { items: true } + if (!order) { + set.status = 404; + return { error: "Order not found" }; + } + + if (status === 'PAID') { + await markOrderAsPaid(order.id); + } else { + await prisma.order.update({ + where: { id: order.id }, + data: { status } + }); + + // If status becomes CANCELLED, refund the points used and notify + if (status === 'CANCELLED' && order.status !== 'CANCELLED') { + if (order.customerId && order.pointsUsed > 0) { + await prisma.customer.update({ + where: { id: order.customerId }, + data: { points: { increment: order.pointsUsed } } + }); + } + sendOrderCancelledNotification(order.id).catch(e => console.error(e)); + } + + // If status becomes SHIPPED, notify + if (status === 'SHIPPED' && order.status !== 'SHIPPED') { + sendOrderShippedNotification(order.id).catch(e => console.error(e)); + } + } + + return await prisma.order.findUnique({ + where: { id: order.id }, + include: { items: true, customer: true } }); - - // Grant points if status becomes PAID and it wasn't PAID before - if (status === 'PAID' && order?.status !== 'PAID' && order?.customerId && order?.pointsEarned > 0) { - await prisma.customer.update({ - where: { id: order.customerId }, - data: { points: { increment: order.pointsEarned } } - }); - } - - // If status becomes CANCELLED, refund the points used - if (status === 'CANCELLED' && order?.status !== 'CANCELLED' && order?.customerId && order?.pointsUsed > 0) { - await prisma.customer.update({ - where: { id: order.customerId }, - data: { points: { increment: order.pointsUsed } } - }); - } - - return updatedOrder; } catch (err) { set.status = 500; return { error: "Failed to update order status" }; @@ -179,11 +202,16 @@ export const ordersRoute = new Elysia({ prefix: "/orders" }) .patch("/:id/tracking", async ({ params: { id }, body, set }) => { try { const { trackingNo } = body as any; - return await prisma.order.update({ + const order = await prisma.order.update({ where: { id: Number(id) }, - data: { trackingNo }, + data: { trackingNo, status: "SHIPPED" }, include: { items: true } }); + // Trigger notification asynchronously + sendOrderShippedNotification(order.id).catch(e => { + console.error("Failed to trigger sendOrderShippedNotification:", e); + }); + return order; } catch (err) { set.status = 500; return { error: "Failed to update tracking number" }; diff --git a/apps/backend/src/routes/payments.ts b/apps/backend/src/routes/payments.ts index 281f717..6e2be0d 100644 --- a/apps/backend/src/routes/payments.ts +++ b/apps/backend/src/routes/payments.ts @@ -1,5 +1,6 @@ import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; +import { markOrderAsPaid } from "../lib/notifications"; const OMISE_SECRET_KEY = process.env.OMISE_SECRET_KEY || ""; @@ -67,10 +68,7 @@ export const paymentsRoute = new Elysia({ prefix: "/payments" }) // If card payment is immediately successful (some cards don't require 3DS) if (charge.status === "successful") { - await prisma.order.update({ - where: { id: orderId }, - data: { status: "PAID" } - }); + await markOrderAsPaid(orderId); } return { @@ -161,10 +159,7 @@ export const paymentsRoute = new Elysia({ prefix: "/payments" }) const orderId = parseInt(orderIdStr, 10); console.log(`[Omise Webhook] Payment successful for Order #${orderId}`); - await prisma.order.update({ - where: { id: orderId }, - data: { status: "PAID" } - }); + await markOrderAsPaid(orderId); } }