feat: implement Phase 1 LINE Message Integration (Order Created, Payment Confirmed, Shipped, Cancelled)

This commit is contained in:
Your Name committed 2026-06-13 00:23:21 -04:00
1 parent 852d9c1925
commit 59363616c4
4 files changed
+630 -34

No files matched your search

+573
View File
@@ -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);
}
}
+2 -2
View File
@@ -37,7 +37,7 @@ const webhookHandler = async ({ request, body, set }: { request: Request, body:
try { try {
const parsedBody = typeof body === 'string' ? JSON.parse(body) : body; 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) { for (const event of events) {
if (event.type === 'message' && event.message.type === 'text') { 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 // Simple echo bot for now
await lineClient.replyMessage({ await lineClient.replyMessage({
replyToken: event.replyToken, replyToken: event.replyToken!,
messages: [{ messages: [{
type: 'text', type: 'text',
text: `คุณส่งข้อความว่า: ${event.message.text}` text: `คุณส่งข้อความว่า: ${event.message.text}`
+52 -24
View File
@@ -1,5 +1,12 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import {
sendOrderCreatedNotification,
sendPaymentConfirmedNotification,
sendOrderShippedNotification,
sendOrderCancelledNotification,
markOrderAsPaid
} from "../lib/notifications";
export const ordersRoute = new Elysia({ prefix: "/orders" }) export const ordersRoute = new Elysia({ prefix: "/orders" })
.get("/", async () => { .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; return order;
} catch (err) { } catch (err) {
console.error("[Orders] Error:", err); console.error("[Orders] Error:", err);
@@ -127,29 +139,40 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
const { status } = body as any; const { status } = body as any;
const order = await prisma.order.findUnique({ where: { id: Number(id) } }); const order = await prisma.order.findUnique({ where: { id: Number(id) } });
const updatedOrder = await prisma.order.update({ if (!order) {
where: { id: Number(id) }, set.status = 404;
data: { status }, return { error: "Order not found" };
include: { items: true } }
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) { } catch (err) {
set.status = 500; set.status = 500;
return { error: "Failed to update order status" }; 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 }) => { .patch("/:id/tracking", async ({ params: { id }, body, set }) => {
try { try {
const { trackingNo } = body as any; const { trackingNo } = body as any;
return await prisma.order.update({ const order = await prisma.order.update({
where: { id: Number(id) }, where: { id: Number(id) },
data: { trackingNo }, data: { trackingNo, status: "SHIPPED" },
include: { items: true } include: { items: true }
}); });
// Trigger notification asynchronously
sendOrderShippedNotification(order.id).catch(e => {
console.error("Failed to trigger sendOrderShippedNotification:", e);
});
return order;
} catch (err) { } catch (err) {
set.status = 500; set.status = 500;
return { error: "Failed to update tracking number" }; return { error: "Failed to update tracking number" };
+3 -8
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { markOrderAsPaid } from "../lib/notifications";
const OMISE_SECRET_KEY = process.env.OMISE_SECRET_KEY || ""; 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 card payment is immediately successful (some cards don't require 3DS)
if (charge.status === "successful") { if (charge.status === "successful") {
await prisma.order.update({ await markOrderAsPaid(orderId);
where: { id: orderId },
data: { status: "PAID" }
});
} }
return { return {
@@ -161,10 +159,7 @@ export const paymentsRoute = new Elysia({ prefix: "/payments" })
const orderId = parseInt(orderIdStr, 10); const orderId = parseInt(orderIdStr, 10);
console.log(`[Omise Webhook] Payment successful for Order #${orderId}`); console.log(`[Omise Webhook] Payment successful for Order #${orderId}`);
await prisma.order.update({ await markOrderAsPaid(orderId);
where: { id: orderId },
data: { status: "PAID" }
});
} }
} }