import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; import { adminOnly } from "../middleware/auth"; export const tiktokRoute = new Elysia({ prefix: "/tiktok" }) .use(adminOnly) .post( "/import/income", async ({ body, set }) => { try { const { data } = body as { data: any[] }; // ── Debug logging ──────────────────────────────────────────────────── console.log(`[TikTok/Income] Received ${data.length} rows`); if (data.length > 0) { console.log(`[TikTok/Income] First row keys:`, Object.keys(data[0])); console.log(`[TikTok/Income] First row id:`, data[0].id); console.log(`[TikTok/Income] First row sample:`, JSON.stringify(data[0]).slice(0, 300)); } // Check for duplicate/missing IDs const ids = data.map((r) => r.id); const uniqueIds = new Set(ids); console.log(`[TikTok/Income] Total IDs: ${ids.length}, Unique IDs: ${uniqueIds.size}`); if (uniqueIds.size < ids.length) { console.warn(`[TikTok/Income] ⚠️ Duplicate IDs detected!`); } // ── Chunked upsert (avoid transaction timeout on large files) ──────── const CHUNK_SIZE = 50; let inserted = 0; for (let i = 0; i < data.length; i += CHUNK_SIZE) { const chunk = data.slice(i, i + CHUNK_SIZE); await prisma.$transaction( chunk.map((row) => prisma.tiktokIncome.upsert({ where: { id: row.id }, update: { type: row.type, orderCreatedTime: row.orderCreatedTime, orderSettledTime: row.orderSettledTime, currency: row.currency, totalSettlementAmount: row.totalSettlementAmount, totalRevenue: row.totalRevenue, totalFees: row.totalFees, rawData: row.rawData, }, create: { id: row.id, type: row.type, orderCreatedTime: row.orderCreatedTime, orderSettledTime: row.orderSettledTime, currency: row.currency, totalSettlementAmount: row.totalSettlementAmount, totalRevenue: row.totalRevenue, totalFees: row.totalFees, rawData: row.rawData, }, }) ), { timeout: 30000 } ); inserted += chunk.length; console.log(`[TikTok/Income] Progress: ${inserted}/${data.length}`); } console.log(`[TikTok/Income] ✅ Done — ${inserted} records upserted`); return { success: true, message: `Imported ${inserted} income records.` }; } catch (error: any) { console.error(`[TikTok/Income] ❌ Error:`, error.message); set.status = 500; return { success: false, message: error.message }; } }, { body: t.Object({ data: t.Array(t.Any()), }), } ) .post( "/import/product", async ({ body, set }) => { try { const { data } = body as { data: any[] }; await prisma.$transaction( data.map((row) => prisma.tiktokProduct.upsert({ where: { id: row.id }, update: { productName: row.productName, status: row.status, gmv: row.gmv, itemsSold: row.itemsSold, orders: row.orders, rawData: row.rawData, }, create: { id: row.id, productName: row.productName, status: row.status, gmv: row.gmv, itemsSold: row.itemsSold, orders: row.orders, rawData: row.rawData, }, }) ) ); return { success: true, message: `Imported ${data.length} product records.` }; } catch (error: any) { set.status = 500; return { success: false, message: error.message }; } }, { body: t.Object({ data: t.Array(t.Any()), }), } ) .post( "/import/product-sku", async ({ body, set }) => { try { const { data } = body as { data: any[] }; await prisma.$transaction( data.map((row) => prisma.tiktokProductSku.upsert({ where: { id: row.id }, update: { productId: row.productId, skuName: row.skuName, status: row.status, gmv: row.gmv, skuOrders: row.skuOrders, itemsSold: row.itemsSold, rawData: row.rawData, }, create: { id: row.id, productId: row.productId, skuName: row.skuName, status: row.status, gmv: row.gmv, skuOrders: row.skuOrders, itemsSold: row.itemsSold, rawData: row.rawData, }, }) ) ); return { success: true, message: `Imported ${data.length} SKU records.` }; } catch (error: any) { set.status = 500; return { success: false, message: error.message }; } }, { body: t.Object({ data: t.Array(t.Any()), }), } ) // ── GET endpoints ──────────────────────────────────────────────────────────── .get("/income", async ({ query }) => { const page = Number(query.page ?? 1); const limit = Number(query.limit ?? 50); const skip = (page - 1) * limit; const [items, total] = await Promise.all([ prisma.tiktokIncome.findMany({ orderBy: { orderCreatedTime: "desc" }, skip, take: limit, }), prisma.tiktokIncome.count(), ]); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; }) .get("/product", async ({ query }) => { const page = Number(query.page ?? 1); const limit = Number(query.limit ?? 50); const skip = (page - 1) * limit; const [items, total] = await Promise.all([ prisma.tiktokProduct.findMany({ orderBy: { gmv: "desc" }, skip, take: limit, }), prisma.tiktokProduct.count(), ]); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; }) .get("/product-sku", async ({ query }) => { const page = Number(query.page ?? 1); const limit = Number(query.limit ?? 50); const skip = (page - 1) * limit; const [items, total] = await Promise.all([ prisma.tiktokProductSku.findMany({ orderBy: { gmv: "desc" }, skip, take: limit, }), prisma.tiktokProductSku.count(), ]); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; }) .get("/summary", async () => { const [incomeCount, productCount, skuCount, incomeAgg, productAgg] = await Promise.all([ prisma.tiktokIncome.count(), prisma.tiktokProduct.count(), prisma.tiktokProductSku.count(), prisma.tiktokIncome.aggregate({ _sum: { totalRevenue: true, totalSettlementAmount: true } }), prisma.tiktokProduct.aggregate({ _sum: { gmv: true, itemsSold: true, orders: true } }), ]); return { incomeCount, productCount, skuCount, totalRevenue: incomeAgg._sum.totalRevenue ?? 0, totalSettlement: incomeAgg._sum.totalSettlementAmount ?? 0, totalGmv: productAgg._sum.gmv ?? 0, totalItemsSold: productAgg._sum.itemsSold ?? 0, totalOrders: productAgg._sum.orders ?? 0, }; }) .get("/analysis/sku", async () => { // 1. Top 10 by GMV const topGmv = await prisma.$queryRaw` WITH SkuStats AS ( SELECT "id", "productId", "skuName", "gmv", "skuOrders", "itemsSold", CASE WHEN "itemsSold" > 0 THEN "gmv" / "itemsSold" ELSE 0 END AS "avgPrice", CASE WHEN "skuOrders" > 0 THEN "gmv" / "skuOrders" ELSE 0 END AS "aov" FROM "TiktokProductSku" ) SELECT * FROM SkuStats ORDER BY "gmv" DESC LIMIT 10 `; // 2. Top 10 by Volume (itemsSold) const topVolume = await prisma.$queryRaw` WITH SkuStats AS ( SELECT "id", "productId", "skuName", "gmv", "skuOrders", "itemsSold", CASE WHEN "itemsSold" > 0 THEN "gmv" / "itemsSold" ELSE 0 END AS "avgPrice", CASE WHEN "skuOrders" > 0 THEN "gmv" / "skuOrders" ELSE 0 END AS "aov" FROM "TiktokProductSku" ) SELECT * FROM SkuStats ORDER BY "itemsSold" DESC LIMIT 10 `; // 3. Scent/Variant Grouping const scentAnalysis = await prisma.$queryRaw` WITH SplitNames AS ( SELECT "id", "gmv", "itemsSold", "skuOrders", TRIM(SPLIT_PART("skuName", ':', 2)) AS "scentName" FROM "TiktokProductSku" WHERE "skuName" LIKE '%:%' ) SELECT "scentName", SUM("gmv") AS "totalGmv", SUM("itemsSold") AS "totalItems", SUM("skuOrders") AS "totalOrders", COUNT("id") AS "skuCount" FROM SplitNames GROUP BY "scentName" ORDER BY "totalGmv" DESC LIMIT 15 `; // 4. Overall Summary (Average AOV, Total GMV) const summaryList = await prisma.$queryRaw` SELECT SUM("gmv") AS "totalGmv", SUM("itemsSold") AS "totalItems", SUM("skuOrders") AS "totalOrders", CASE WHEN SUM("skuOrders") > 0 THEN SUM("gmv") / SUM("skuOrders") ELSE 0 END AS "avgAov", CASE WHEN SUM("itemsSold") > 0 THEN SUM("gmv") / SUM("itemsSold") ELSE 0 END AS "avgPrice" FROM "TiktokProductSku" `; // Handle BigInt conversion since Prisma $queryRaw returns BigInts for COUNT/SUM in Postgres sometimes. const serialize = (data: any) => JSON.parse(JSON.stringify(data, (key, value) => (typeof value === "bigint" ? Number(value) : value))); return serialize({ summary: summaryList[0] || null, topGmv, topVolume, scentAnalysis }); });