import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; export const crmRoute = new Elysia({ prefix: "/crm" }) // ── 1. Import Single / Multiple Customers ───────────────────────────────── .post( "/import", async ({ body, set }) => { try { const { customers } = body as { customers: Array<{ channel: string; shop?: string; identifier: string; displayName: string; location?: string; province?: string; district?: string; orderCount?: number; totalSpent?: number; avgIntervalDays?: number; firstOrderDate?: string; lastOrderDate?: string; predictedNextOrderDate?: string; topProducts?: string[]; initialStatus?: string; strategyType?: string; promoCode?: string; discountOffered?: string; notes?: string; }>; }; if (!customers || !Array.isArray(customers) || customers.length === 0) { set.status = 400; return { error: "customers array is required" }; } const results = []; for (const c of customers) { const channel = (c.channel || "tiktok").toLowerCase(); const identifier = (c.identifier || "").trim(); if (!identifier) continue; // Upsert MarketplaceCustomer const cust = await prisma.marketplaceCustomer.upsert({ where: { channel_identifier: { channel, identifier, }, }, update: { displayName: c.displayName || identifier, shop: c.shop || "hom-aroma", location: c.location || undefined, province: c.province || undefined, district: c.district || undefined, orderCount: typeof c.orderCount === "number" ? c.orderCount : undefined, totalSpent: typeof c.totalSpent === "number" ? c.totalSpent : undefined, avgIntervalDays: typeof c.avgIntervalDays === "number" ? c.avgIntervalDays : undefined, firstOrderDate: c.firstOrderDate || undefined, lastOrderDate: c.lastOrderDate || undefined, predictedNextOrderDate: c.predictedNextOrderDate || undefined, topProducts: c.topProducts ? (c.topProducts as any) : undefined, }, create: { channel, shop: c.shop || "hom-aroma", identifier, displayName: c.displayName || identifier, location: c.location || "", province: c.province || "", district: c.district || "", orderCount: c.orderCount || 0, totalSpent: c.totalSpent || 0, avgIntervalDays: c.avgIntervalDays || 0, firstOrderDate: c.firstOrderDate || "", lastOrderDate: c.lastOrderDate || "", predictedNextOrderDate: c.predictedNextOrderDate || "", topProducts: c.topProducts ? (c.topProducts as any) : [], }, }); // Check if CRM record exists let crm = await prisma.marketplaceCustomerCrm.findUnique({ where: { customerId: cust.id }, }); if (!crm) { crm = await prisma.marketplaceCustomerCrm.create({ data: { customerId: cust.id, status: c.initialStatus || "pending", strategyType: c.strategyType || "due_restock", promoCodeSent: c.promoCode || null, discountOffered: c.discountOffered || null, notes: c.notes || null, activities: { create: { action: "IMPORT", content: `นำเข้าลูกค้าจาก Action Plan (${channel})`, author: "System", }, }, }, }); } results.push({ id: cust.id, identifier: cust.identifier, channel: cust.channel, displayName: cust.displayName, crmId: crm.id, status: crm.status, }); } return { success: true, importedCount: results.length, results, }; } catch (err: any) { set.status = 500; return { error: err.message || "Failed to import customers" }; } }, { body: t.Object({ customers: t.Array( t.Object({ channel: t.String(), shop: t.Optional(t.String()), identifier: t.String(), displayName: t.String(), location: t.Optional(t.String()), province: t.Optional(t.String()), district: t.Optional(t.String()), orderCount: t.Optional(t.Number()), totalSpent: t.Optional(t.Number()), avgIntervalDays: t.Optional(t.Number()), firstOrderDate: t.Optional(t.String()), lastOrderDate: t.Optional(t.String()), predictedNextOrderDate: t.Optional(t.String()), topProducts: t.Optional(t.Array(t.String())), initialStatus: t.Optional(t.String()), strategyType: t.Optional(t.String()), promoCode: t.Optional(t.String()), discountOffered: t.Optional(t.String()), notes: t.Optional(t.String()), }) ), }), } ) // ── 2. Check Which Customers are Already Imported ───────────────────────── .post( "/check-imported", async ({ body, set }) => { try { const { keys } = body as { keys: Array<{ channel: string; identifier: string }>; }; if (!keys || !Array.isArray(keys) || keys.length === 0) { return { importedMap: {} }; } const conditions = keys.map((k) => ({ channel: k.channel.toLowerCase(), identifier: k.identifier.trim(), })); const existing = await prisma.marketplaceCustomer.findMany({ where: { OR: conditions, }, include: { crm: true, }, }); // Map key format: "channel:identifier" => { customerId, crmId, status, notes, promoCodeSent } const importedMap: Record = {}; for (const item of existing) { const mapKey = `${item.channel}:${item.identifier.toLowerCase()}`; importedMap[mapKey] = { id: item.id, channel: item.channel, identifier: item.identifier, displayName: item.displayName, crmId: item.crm?.id, status: item.crm?.status || "pending", notes: item.crm?.notes || "", promoCodeSent: item.crm?.promoCodeSent || "", discountOffered: item.crm?.discountOffered || "", strategyType: item.crm?.strategyType || "due_restock", updatedAt: item.crm?.updatedAt, }; } return { importedMap }; } catch (err: any) { set.status = 500; return { error: err.message || "Failed to check imported customers" }; } }, { body: t.Object({ keys: t.Array( t.Object({ channel: t.String(), identifier: t.String(), }) ), }), } ) // ── 3. List CRM Customers with Stats, Filters, and Pagination ───────────── .get("/customers", async ({ query, set }) => { try { const channel = (query.channel as string) || "all"; const status = (query.status as string) || "all"; const strategyType = (query.strategyType as string) || "all"; const search = (query.search as string) || ""; const page = Math.max(1, parseInt((query.page as string) || "1")); const limit = Math.max(1, Math.min(100, parseInt((query.limit as string) || "20"))); const sortBy = (query.sortBy as string) || "updatedAt"; // 'updatedAt' | 'totalSpent' | 'orderCount' | 'nextFollowUpDate' const sortOrder = (query.sortOrder as string) || "desc"; const where: any = {}; if (channel !== "all") { where.channel = channel; } if (status !== "all") { where.crm = { ...(where.crm || {}), status }; } if (strategyType !== "all") { where.crm = { ...(where.crm || {}), strategyType }; } if (search) { where.OR = [ { displayName: { contains: search, mode: "insensitive" } }, { identifier: { contains: search, mode: "insensitive" } }, { location: { contains: search, mode: "insensitive" } }, { crm: { notes: { contains: search, mode: "insensitive" } } }, ]; } // Total count const totalCount = await prisma.marketplaceCustomer.count({ where }); // Build Order By let orderBy: any = {}; if (sortBy === "totalSpent") { orderBy = { totalSpent: sortOrder }; } else if (sortBy === "orderCount") { orderBy = { orderCount: sortOrder }; } else if (sortBy === "nextFollowUpDate") { orderBy = { crm: { nextFollowUpDate: sortOrder } }; } else { orderBy = { updatedAt: sortOrder }; } const customers = await prisma.marketplaceCustomer.findMany({ where, include: { crm: { include: { activities: { orderBy: { createdAt: "desc" }, take: 5, }, }, }, }, orderBy, skip: (page - 1) * limit, take: limit, }); // Overall CRM summary metrics const [ totalInCrm, pendingCount, contactedCount, convertedCount, unresponsiveCount, aggregateStats, ] = await Promise.all([ prisma.marketplaceCustomerCrm.count(), prisma.marketplaceCustomerCrm.count({ where: { status: "pending" } }), prisma.marketplaceCustomerCrm.count({ where: { status: "contacted" } }), prisma.marketplaceCustomerCrm.count({ where: { status: "converted" } }), prisma.marketplaceCustomerCrm.count({ where: { status: "unresponsive" } }), prisma.marketplaceCustomer.aggregate({ _sum: { totalSpent: true }, _avg: { avgIntervalDays: true }, }), ]); const conversionRate = contactedCount + convertedCount > 0 ? Number(((convertedCount / (contactedCount + convertedCount)) * 100).toFixed(1)) : 0; return { summary: { totalInCrm, pendingCount, contactedCount, convertedCount, unresponsiveCount, conversionRate, totalRevenueInCrm: aggregateStats._sum.totalSpent || 0, }, pagination: { page, limit, totalCount, totalPages: Math.ceil(totalCount / limit), }, customers: customers.map((c) => ({ id: c.id, channel: c.channel, shop: c.shop, identifier: c.identifier, displayName: c.displayName, location: c.location, province: c.province, district: c.district, orderCount: c.orderCount, totalSpent: c.totalSpent, avgIntervalDays: c.avgIntervalDays, firstOrderDate: c.firstOrderDate, lastOrderDate: c.lastOrderDate, predictedNextOrderDate: c.predictedNextOrderDate, topProducts: c.topProducts, crm: c.crm ? { id: c.crm.id, status: c.crm.status, strategyType: c.crm.strategyType, promoCodeSent: c.crm.promoCodeSent, discountOffered: c.crm.discountOffered, lastContactedAt: c.crm.lastContactedAt, nextFollowUpDate: c.crm.nextFollowUpDate, notes: c.crm.notes, assignedTo: c.crm.assignedTo, activities: c.crm.activities, updatedAt: c.crm.updatedAt, } : null, })), }; } catch (err: any) { set.status = 500; return { error: err.message || "Failed to list CRM customers" }; } }) // ── 3.1 Get Single CRM Customer Detail with Order History ───────────────── .get("/customers/:id", async ({ params, set }) => { try { const id = parseInt(params.id); const cust = await prisma.marketplaceCustomer.findUnique({ where: { id }, include: { crm: { include: { activities: { orderBy: { createdAt: "desc" }, }, }, }, }, }); if (!cust) { set.status = 404; return { error: "Customer not found" }; } // Fetch order history for this customer based on channel & identifier let orders: any[] = []; const channel = cust.channel.toLowerCase(); const identifier = cust.identifier; if (channel === "tiktok") { const dbOrders = await prisma.tiktokOrder.findMany({ where: { buyerUsername: identifier, }, orderBy: { createdTime: "desc" }, include: { items: true, }, }); orders = dbOrders.map((o) => ({ orderId: o.orderId, channel: "tiktok", shop: o.shop, date: o.createdTime, status: o.status, amount: Number(o.orderAmount) || 0, shippingFee: Number(o.shippingFee) || 0, trackingId: o.trackingId, deliveryOption: o.deliveryOption, items: o.items.map((it) => ({ name: it.productName, variation: it.variation, quantity: it.quantity, price: Number(it.subtotalAfterDiscount || it.unitOriginalPrice) || 0, })), })); } else if (channel === "shopee") { const dbOrders = await prisma.shopeeOrder.findMany({ where: { buyerUsername: identifier, }, orderBy: { orderCreatedAt: "desc" }, include: { items: true, }, }); orders = dbOrders.map((o) => ({ orderId: o.orderId, channel: "shopee", shop: o.shop, date: o.orderCreatedAt, status: o.status, amount: Number(o.buyerPaidAmount || o.totalAmount) || 0, shippingFee: Number(o.shippingFee) || 0, trackingId: o.trackingNo, items: o.items.map((it) => ({ name: it.productName, variation: it.variantName, quantity: it.quantity, price: Number(it.dealPrice || it.originalPrice) || 0, })), })); } else if (channel === "lazada") { const parts = identifier.split("_"); const namePart = parts[0]; const postCodePart = parts[1] || ""; const where: any = { customerName: { equals: namePart, mode: "insensitive" }, }; if (postCodePart) { where.shippingPostCode = postCodePart; } const dbOrders = await prisma.lazadaOrder.findMany({ where, orderBy: { createTime: "desc" }, include: { items: true, }, }); orders = dbOrders.map((o) => ({ orderId: o.orderNumber, channel: "lazada", shop: o.shop, date: o.createTime, status: o.status || "Completed", amount: Number(o.orderAmount) || 0, shippingFee: Number(o.shippingFee) || 0, trackingId: o.trackingCode, items: o.items.map((it) => ({ name: it.itemName, variation: it.variation, quantity: 1, price: Number(it.paidPrice || it.unitPrice) || 0, })), })); } return { customer: cust, orders, }; } catch (err: any) { set.status = 500; return { error: err.message || "Failed to fetch customer detail" }; } }) // ── 4. Update CRM Status & Notes ────────────────────────────────────────── .patch( "/customers/:id", async ({ params, body, set }) => { try { const id = parseInt(params.id); const { status, strategyType, notes, promoCodeSent, discountOffered, nextFollowUpDate, assignedTo, activityAction, activityContent, author, } = body as { status?: string; strategyType?: string; notes?: string; promoCodeSent?: string; discountOffered?: string; nextFollowUpDate?: string | null; assignedTo?: string; activityAction?: string; activityContent?: string; author?: string; }; // Find customer const cust = await prisma.marketplaceCustomer.findUnique({ where: { id }, include: { crm: true }, }); if (!cust) { set.status = 404; return { error: "Marketplace customer not found" }; } const updateData: any = {}; if (status) { updateData.status = status; if (status === "contacted" || status === "converted") { updateData.lastContactedAt = new Date(); } } if (strategyType) updateData.strategyType = strategyType; if (typeof notes !== "undefined") updateData.notes = notes; if (typeof promoCodeSent !== "undefined") updateData.promoCodeSent = promoCodeSent; if (typeof discountOffered !== "undefined") updateData.discountOffered = discountOffered; if (typeof assignedTo !== "undefined") updateData.assignedTo = assignedTo; if (typeof nextFollowUpDate !== "undefined") { updateData.nextFollowUpDate = nextFollowUpDate ? new Date(nextFollowUpDate) : null; } let crm = cust.crm; if (!crm) { crm = await prisma.marketplaceCustomerCrm.create({ data: { customerId: cust.id, status: status || "pending", strategyType: strategyType || "due_restock", ...updateData, }, }); } else { crm = await prisma.marketplaceCustomerCrm.update({ where: { id: crm.id }, data: updateData, }); } // Add Activity Log if supplied or if status changed if (activityAction && activityContent) { await prisma.customerCrmActivity.create({ data: { crmId: crm.id, action: activityAction, content: activityContent, author: author || "Admin", }, }); } else if (status && status !== cust.crm?.status) { const statusLabels: Record = { pending: "⏳ รอดำเนินการ", contacted: "💬 ส่งโปรแล้ว", converted: "✅ ซื้อซ้ำสำเร็จ", unresponsive: "❌ ลูกค้าไม่ตอบ/ไม่สนใจ", do_not_disturb: "⛔ ขอไม่รับการติดต่อ", }; await prisma.customerCrmActivity.create({ data: { crmId: crm.id, action: "STATUS_CHANGE", content: `เปลี่ยนสถานะเป็น: ${statusLabels[status] || status}`, author: author || "Admin", }, }); } return { success: true, customer: { ...cust, crm, }, }; } catch (err: any) { set.status = 500; return { error: err.message || "Failed to update CRM status" }; } }, { body: t.Object({ status: t.Optional(t.String()), strategyType: t.Optional(t.String()), notes: t.Optional(t.String()), promoCodeSent: t.Optional(t.String()), discountOffered: t.Optional(t.String()), nextFollowUpDate: t.Optional(t.Union([t.String(), t.Null()])), assignedTo: t.Optional(t.String()), activityAction: t.Optional(t.String()), activityContent: t.Optional(t.String()), author: t.Optional(t.String()), }), } ) // ── 5. Add Custom Activity Note ─────────────────────────────────────────── .post( "/customers/:id/activities", async ({ params, body, set }) => { try { const customerId = parseInt(params.id); const { action, content, author } = body as { action: string; content: string; author?: string; }; const crm = await prisma.marketplaceCustomerCrm.findUnique({ where: { customerId }, }); if (!crm) { set.status = 404; return { error: "Customer CRM record not found" }; } const activity = await prisma.customerCrmActivity.create({ data: { crmId: crm.id, action: action || "NOTE", content, author: author || "Admin", }, }); return { success: true, activity }; } catch (err: any) { set.status = 500; return { error: err.message || "Failed to add activity" }; } }, { body: t.Object({ action: t.String(), content: t.String(), author: t.Optional(t.String()), }), } );