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 () => { return await prisma.order.findMany({ orderBy: { createdAt: 'desc' }, include: { items: true, customer: true } }); }) .get("/:id", async ({ params: { id }, query, set }) => { const order = await prisma.order.findUnique({ where: { id: Number(id) }, include: { items: true, customer: true } }); if (!order) { set.status = 404; return { error: "Order not found" }; } const lang = (query as any)?.lang; if (lang && lang !== 'all') { const missingProductIds = order.items .filter((item: any) => !item.translations || !(item.translations as any)?.name) .map((item: any) => item.productId) .filter(Boolean); let legacyTranslationMap = new Map>(); if (missingProductIds.length > 0) { const legacyTranslations = await prisma.productTranslation.findMany({ where: { productId: { in: missingProductIds } }, select: { productId: true, langCode: true, name: true } }); for (const pt of legacyTranslations) { if (!legacyTranslationMap.has(pt.productId)) legacyTranslationMap.set(pt.productId, {}); legacyTranslationMap.get(pt.productId)![pt.langCode] = pt.name; } } const localizedItems = order.items.map((item: any) => { const snapshotName = (item.translations as any)?.name?.[lang]; const legacyName = legacyTranslationMap.get(item.productId)?.[lang]; const localizedName = snapshotName || legacyName || item.productName; return { ...item, productName: localizedName, }; }); return { ...order, items: localizedItems, }; } return order; }) .post("/", async ({ body, set }) => { try { const data = body as any; const settings = await prisma.storeSettings.findFirst() const earnRate = settings?.pointsEarnRate || 1.0 const shippingFee = data.shippingFee || 0 const netAmount = (data.totalAmount || 0) - shippingFee const pointsEarned = Number.isFinite(netAmount) ? Math.floor(Math.max(0, netAmount) * earnRate) : 0 if (data.customerId && data.pointsUsed && data.pointsUsed > 0) { // Verify customer has enough points const customer = await prisma.customer.findUnique({ where: { id: data.customerId } }) if (!customer || customer.points < data.pointsUsed) { set.status = 400 return { error: "Insufficient points" } } // Deduct points immediately await prisma.customer.update({ where: { id: data.customerId }, data: { points: { decrement: data.pointsUsed } } }) } const isPos = data.channel === "POS"; const initialStatus = isPos ? "COMPLETED" : "PENDING_PAYMENT"; // Pre-fetch product names across all languages for immutable snapshotting const productIds = Array.from(new Set((data.items || []).map((item: any) => Number(item.productId)).filter(Boolean))) as number[]; const [productRecords, productTranslations] = await Promise.all([ productIds.length > 0 ? prisma.product.findMany({ where: { id: { in: productIds } }, select: { id: true, name: true } }) : [], productIds.length > 0 ? prisma.productTranslation.findMany({ where: { productId: { in: productIds } }, select: { productId: true, langCode: true, name: true } }) : [] ]); const translationMap = new Map>(); for (const p of productRecords) { if (p.name) { if (!translationMap.has(p.id)) translationMap.set(p.id, {}); translationMap.get(p.id)!['th'] = p.name; } } for (const pt of productTranslations) { if (pt.name) { if (!translationMap.has(pt.productId)) translationMap.set(pt.productId, {}); translationMap.get(pt.productId)![pt.langCode] = pt.name; } } const order = await prisma.order.create({ data: { channel: data.channel || "ONLINE", customerId: data.customerId, customerName: data.customerName || (isPos ? "ลูกค้าหน้าร้าน (Walk-in)" : null), customerEmail: data.customerEmail || null, customerPhone: data.customerPhone || null, shippingAddress: data.shippingAddress || (isPos ? "หน้าร้าน (Takeaway)" : null), subtotal: data.subtotal || 0, promoLevel1Name: data.promoLevel1Name, promoLevel1Disc: data.promoLevel1Disc || 0, promoLevel2Name: data.promoLevel2Name, promoLevel2Disc: data.promoLevel2Disc || 0, couponCode: data.couponCode || null, discountAmount: data.discountAmount || 0, pointsUsed: data.pointsUsed || 0, pointsDiscount: data.pointsDiscount || 0, pointsEarned: pointsEarned, shippingFee: data.shippingFee || 0, totalAmount: data.totalAmount, status: initialStatus, paymentMethod: data.paymentMethod || (isPos ? "CASH" : null), receivedAmount: data.receivedAmount, changeAmount: data.changeAmount, cashierId: data.cashierId, items: { create: data.items.map((item: any) => { const names = translationMap.get(Number(item.productId)) || {}; const translationsSnapshot = Object.keys(names).length > 0 ? { name: names } : undefined; return { productId: item.productId, variantId: item.variantId, quantity: item.quantity, priceAtPurchase: item.priceAtPurchase, productName: item.productName, variantName: item.variantName, productImage: item.productImage, translations: translationsSnapshot, }; }) } }, include: { items: true } }); // For POS orders: Deduct stock immediately and credit points if (isPos) { for (const item of data.items) { if (item.variantId) { try { await prisma.productVariant.update({ where: { id: item.variantId }, data: { stock: { decrement: item.quantity } } }); } catch (err) { console.warn(`[POS] Failed to decrement stock for variant ${item.variantId}:`, err); } } } if (data.customerId && pointsEarned > 0) { try { await prisma.customer.update({ where: { id: data.customerId }, data: { points: { increment: pointsEarned } } }); } catch (err) { console.warn(`[POS] Failed to credit points for customer ${data.customerId}:`, err); } } } // If a coupon was used, increment its usedCount if (data.couponCode) { try { await prisma.coupon.update({ where: { code: data.couponCode.toUpperCase() }, data: { usedCount: { increment: 1 } } }); } catch (e) { console.error("Failed to increment coupon usedCount:", e); } } // 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); set.status = 500; return { error: "Failed to create order" }; } }, { body: t.Object({ channel: t.Optional(t.String()), customerId: t.Optional(t.Nullable(t.Number())), customerName: t.Optional(t.Nullable(t.String())), customerEmail: t.Optional(t.Nullable(t.String())), customerPhone: t.Optional(t.Nullable(t.String())), shippingAddress: t.Optional(t.Nullable(t.String())), paymentMethod: t.Optional(t.Nullable(t.String())), receivedAmount: t.Optional(t.Nullable(t.Number())), changeAmount: t.Optional(t.Nullable(t.Number())), cashierId: t.Optional(t.Nullable(t.Number())), totalAmount: t.Number(), shippingFee: t.Optional(t.Number()), subtotal: t.Optional(t.Number()), promoLevel1Name: t.Optional(t.Nullable(t.String())), promoLevel1Disc: t.Optional(t.Number()), promoLevel2Name: t.Optional(t.Nullable(t.String())), promoLevel2Disc: t.Optional(t.Number()), couponCode: t.Optional(t.Nullable(t.String())), discountAmount: t.Optional(t.Number()), pointsUsed: t.Optional(t.Number()), pointsDiscount: t.Optional(t.Number()), items: t.Array(t.Object({ productId: t.Number(), variantId: t.Optional(t.Number()), quantity: t.Number(), priceAtPurchase: t.Number(), productName: t.String(), variantName: t.Optional(t.String()), productImage: t.Optional(t.String()) })) }) }) .patch("/:id/status", async ({ params: { id }, body, set }) => { try { const { status } = body as any; const order = await prisma.order.findUnique({ where: { id: Number(id) } }); 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 } }); } catch (err) { set.status = 500; return { error: "Failed to update order status" }; } }, { body: t.Object({ status: t.String() }) }) .patch("/:id/slip", async ({ params: { id }, body, set }) => { try { const { paymentSlipUrl } = body as any; return await prisma.order.update({ where: { id: Number(id) }, data: { paymentSlipUrl }, include: { items: true } }); } catch (err) { set.status = 500; return { error: "Failed to upload slip" }; } }, { body: t.Object({ paymentSlipUrl: t.String() }) }) .patch("/:id/tracking", async ({ params: { id }, body, set }) => { try { const { trackingNo } = body as any; const order = await prisma.order.update({ where: { id: Number(id) }, 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" }; } }, { body: t.Object({ trackingNo: t.String() }) }) .get("/customer/:customerId", async ({ params: { customerId } }) => { return await prisma.order.findMany({ where: { customerId: Number(customerId) }, orderBy: { createdAt: 'desc' }, include: { items: true } }); }) .post("/track", async ({ body, set }) => { try { const { orderId, phone } = body as any; const order = await prisma.order.findUnique({ where: { id: Number(orderId) } }); if (!order) { set.status = 404; return { error: "Order not found" }; } const sanitizePhone = (p: string) => p.replace(/[^0-9]/g, ''); if (sanitizePhone(order.customerPhone) !== sanitizePhone(phone)) { set.status = 401; return { error: "Authentication failed. Phone number does not match." }; } return { success: true, orderId: order.id }; } catch (err) { set.status = 500; return { error: "Invalid tracking request" }; } }, { body: t.Object({ orderId: t.Number(), phone: t.String() }) });