This commit is contained in:
Your Name committed 2026-09-14 11:46:07 +07:00
1 parent d5ea45a536
commit afd6904c48
20 files changed
+5435 -51

No files matched your search

@@ -0,0 +1,90 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "MarketplaceCustomer" (
"id" SERIAL NOT NULL,
"channel" TEXT NOT NULL,
"shop" TEXT NOT NULL DEFAULT 'hom-aroma',
"identifier" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"location" TEXT,
"province" TEXT,
"district" TEXT,
"orderCount" INTEGER NOT NULL DEFAULT 0,
"totalSpent" DOUBLE PRECISION NOT NULL DEFAULT 0,
"avgIntervalDays" DOUBLE PRECISION NOT NULL DEFAULT 0,
"firstOrderDate" TEXT,
"lastOrderDate" TEXT,
"predictedNextOrderDate" TEXT,
"topProducts" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketplaceCustomer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "MarketplaceCustomerCrm" (
"id" SERIAL NOT NULL,
"customerId" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"strategyType" TEXT NOT NULL DEFAULT 'due_restock',
"promoCodeSent" TEXT,
"discountOffered" TEXT,
"lastContactedAt" TIMESTAMP(3),
"nextFollowUpDate" TIMESTAMP(3),
"notes" TEXT,
"assignedTo" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketplaceCustomerCrm_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "CustomerCrmActivity" (
"id" SERIAL NOT NULL,
"crmId" INTEGER NOT NULL,
"action" TEXT NOT NULL,
"content" TEXT NOT NULL,
"author" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerCrmActivity_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomer_channel_idx" ON "MarketplaceCustomer"("channel");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomer_totalSpent_idx" ON "MarketplaceCustomer"("totalSpent");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomer_orderCount_idx" ON "MarketplaceCustomer"("orderCount");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "MarketplaceCustomer_channel_identifier_key" ON "MarketplaceCustomer"("channel", "identifier");
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_customerId_key" ON "MarketplaceCustomerCrm"("customerId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_status_idx" ON "MarketplaceCustomerCrm"("status");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_strategyType_idx" ON "MarketplaceCustomerCrm"("strategyType");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "MarketplaceCustomerCrm_nextFollowUpDate_idx" ON "MarketplaceCustomerCrm"("nextFollowUpDate");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "CustomerCrmActivity_crmId_idx" ON "CustomerCrmActivity"("crmId");
-- AddForeignKey
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'MarketplaceCustomerCrm_customerId_fkey') THEN
ALTER TABLE "MarketplaceCustomerCrm" ADD CONSTRAINT "MarketplaceCustomerCrm_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "MarketplaceCustomer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CustomerCrmActivity_crmId_fkey') THEN
ALTER TABLE "CustomerCrmActivity" ADD CONSTRAINT "CustomerCrmActivity_crmId_fkey" FOREIGN KEY ("crmId") REFERENCES "MarketplaceCustomerCrm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
+72
View File
@@ -790,3 +790,75 @@ model MarketplaceProduct {
@@index([totalRevenue])
}
// ── Marketplace Customers & CRM ───────────────────────────────────────────────
model MarketplaceCustomer {
id Int @id @default(autoincrement())
channel String // "tiktok" | "shopee" | "lazada"
shop String @default("hom-aroma")
identifier String // Unique identifier per channel (username or name+postcode)
displayName String // Display name on UI
location String? // District, Province
province String?
district String?
// Aggregated buyer stats
orderCount Int @default(0)
totalSpent Float @default(0)
avgIntervalDays Float @default(0)
firstOrderDate String?
lastOrderDate String?
predictedNextOrderDate String?
topProducts Json? // Stored as JSON array: string[]
// CRM Relationship
crm MarketplaceCustomerCrm?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([channel, identifier])
@@index([channel])
@@index([totalSpent])
@@index([orderCount])
}
model MarketplaceCustomerCrm {
id Int @id @default(autoincrement())
customerId Int @unique
customer MarketplaceCustomer @relation(fields: [customerId], references: [id], onDelete: Cascade)
// Tracking status
status String @default("pending") // "pending" | "contacted" | "converted" | "unresponsive" | "do_not_disturb"
strategyType String @default("due_restock") // "due_restock" | "win_back" | "vip_reward" | "nurture"
// Campaign & contact details
promoCodeSent String? // e.g., "REPEAT10"
discountOffered String? // e.g., "ส่วนลด 10%"
lastContactedAt DateTime?
nextFollowUpDate DateTime?
notes String?
assignedTo String?
activities CustomerCrmActivity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([strategyType])
@@index([nextFollowUpDate])
}
model CustomerCrmActivity {
id Int @id @default(autoincrement())
crmId Int
crm MarketplaceCustomerCrm @relation(fields: [crmId], references: [id], onDelete: Cascade)
action String // "STATUS_CHANGE" | "SENT_PROMO" | "CALL_NOTE" | "CHAT_NOTE"
content String
author String?
createdAt DateTime @default(now())
@@index([crmId])
}
+2 -1
View File
@@ -31,6 +31,7 @@ import { productAttributesRoute } from "./routes/product-attributes";
import { backupRoute } from "./routes/backup";
import { businessAnalyticsRoute } from "./routes/businessAnalytics";
import { toolsRoute } from "./routes/tools";
import { crmRoute } from "./routes/crm";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
@@ -138,7 +139,7 @@ const app = new Elysia()
return file;
})
.group("/api", (app) =>
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute)
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute)
)
.listen({ port: PORT, hostname: "0.0.0.0" });
@@ -1872,6 +1872,12 @@ export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics"
.slice(0, 3)
.map(([name]) => name);
let predictedNextOrderDate = "";
if (c.lastTimestamp > 0 && c.daysBetweenOrders > 0) {
const predictedTs = c.lastTimestamp + c.daysBetweenOrders * 24 * 60 * 60 * 1000;
predictedNextOrderDate = new Date(predictedTs).toISOString().slice(0, 10);
}
return {
id: c.id,
identifier: c.identifier,
@@ -1885,6 +1891,7 @@ export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics"
firstOrderDate: c.firstOrderDate,
lastOrderDate: c.lastOrderDate,
daysBetweenOrders: c.daysBetweenOrders,
predictedNextOrderDate,
location: c.location,
topProducts: sortedProds,
};
+670
View File
@@ -0,0 +1,670 @@
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<string, any> = {};
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<string, string> = {
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()),
}),
}
);