From 23ab9983114fed0b406564916f9ffa4e2ed66fd5 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 12 May 2026 00:30:41 +0500 Subject: [PATCH] fix: ai analytics --- .../api/dashboard/ai-analytics/route.test.ts | 74 ++++++ src/app/api/dashboard/ai-analytics/route.ts | 217 +++++++++++++++++- 2 files changed, 289 insertions(+), 2 deletions(-) diff --git a/src/app/api/dashboard/ai-analytics/route.test.ts b/src/app/api/dashboard/ai-analytics/route.test.ts index f28a5e9..95b6489 100644 --- a/src/app/api/dashboard/ai-analytics/route.test.ts +++ b/src/app/api/dashboard/ai-analytics/route.test.ts @@ -16,6 +16,7 @@ vi.mock("@/lib/prisma", () => ({ storefrontChatConversation: { findMany: vi.fn() }, cachedOrder: { findMany: vi.fn() }, chatMessage: { findMany: vi.fn() }, + emailLog: { findMany: vi.fn() }, }, })) @@ -33,16 +34,20 @@ describe("GET /api/dashboard/ai-analytics", () => { vi.mocked(getVendorActiveStore).mockResolvedValue({ id: "store-1", userId: "user-1" } as never) vi.mocked(prisma.storefrontChatMessage.findMany).mockResolvedValue([ { + id: "m1", senderType: "CUSTOMER", createdAt: new Date("2026-05-01T10:00:00Z"), content: "Do you have school shoes?", conversationId: "conv-1", + actions: null, }, { + id: "m2", senderType: "AI", createdAt: new Date("2026-05-01T10:00:03Z"), content: "Yes, we have school shoes.", conversationId: "conv-1", + actions: null, }, ] as never) vi.mocked(prisma.aiAnalyticsEvent.findMany).mockResolvedValue([ @@ -95,6 +100,7 @@ describe("GET /api/dashboard/ai-analytics", () => { ] as never) vi.mocked(prisma.cachedOrder.findMany).mockResolvedValue([] as never) vi.mocked(prisma.chatMessage.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.emailLog.findMany).mockResolvedValue([] as never) const res = await GET(new Request("http://localhost/api/dashboard/ai-analytics?range=30d")) const json = await res.json() @@ -109,4 +115,72 @@ describe("GET /api/dashboard/ai-analytics", () => { expect(json.automationOutcomes.questionsAnswered).toBe(1) expect(Array.isArray(json.timeline)).toBe(true) }) + + it("derives real metrics from existing messages when persisted analytics events are not backfilled yet", async () => { + const { auth } = await import("@/auth") + const { getVendorActiveStore } = await import("@/lib/dashboard/vendorActiveStore") + const { prisma } = await import("@/lib/prisma") + + vi.mocked(auth).mockResolvedValue({ user: { id: "user-1", role: "VENDOR" } } as never) + vi.mocked(getVendorActiveStore).mockResolvedValue({ id: "store-1", userId: "user-1" } as never) + vi.mocked(prisma.storefrontChatMessage.findMany).mockResolvedValue([ + { + id: "m1", + senderType: "CUSTOMER", + createdAt: new Date("2026-05-01T10:00:00Z"), + content: "Thank you, do you have sandals?", + conversationId: "conv-1", + actions: null, + }, + { + id: "m2", + senderType: "AI", + createdAt: new Date("2026-05-01T10:00:03Z"), + content: "Yes, we have sandals available.", + conversationId: "conv-1", + actions: [ + { + type: "upsell_suggestion", + label: "Add suggested item", + message: "Would you like socks too?", + product: { productId: "p2", title: "Socks", variantId: "v2", price: "5.00" }, + }, + ], + }, + ] as never) + vi.mocked(prisma.aiAnalyticsEvent.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.storefrontChatConversation.findMany).mockResolvedValue([ + { + id: "conv-1", + visitorId: "visitor-1", + customerEmail: "buyer@example.com", + boundShopifyOrderId: "order-1", + createdAt: new Date("2026-05-01T09:59:00Z"), + updatedAt: new Date("2026-05-01T10:00:03Z"), + }, + ] as never) + vi.mocked(prisma.cachedOrder.findMany).mockResolvedValue([ + { + shopifyOrderId: "order-1", + orderNumber: "#1001", + email: "buyer@example.com", + totalPrice: "30.00", + currency: "GBP", + lineItems: [], + shopifyCreatedAt: new Date("2026-05-01T10:10:00Z"), + createdAt: new Date("2026-05-01T10:10:00Z"), + }, + ] as never) + vi.mocked(prisma.chatMessage.findMany).mockResolvedValue([] as never) + vi.mocked(prisma.emailLog.findMany).mockResolvedValue([] as never) + + const res = await GET(new Request("http://localhost/api/dashboard/ai-analytics?range=30d")) + const json = await res.json() + + expect(res.status).toBe(200) + expect(json.customerExperience.sentimentMix.totals.positive).toBe(1) + expect(json.automationOutcomes.resolvedByAi).toBe(1) + expect(json.revenueImpact.aiAssistedRevenue).toBe(30) + expect(json.technicalDiagnostics.derivedAnalyticsEvents).toBeGreaterThan(0) + }) }) diff --git a/src/app/api/dashboard/ai-analytics/route.ts b/src/app/api/dashboard/ai-analytics/route.ts index 8395f87..0da35f7 100644 --- a/src/app/api/dashboard/ai-analytics/route.ts +++ b/src/app/api/dashboard/ai-analytics/route.ts @@ -5,12 +5,16 @@ import { getVendorActiveStore } from "@/lib/dashboard/vendorActiveStore" import { classifyStorefrontCustomerQuery } from "@/lib/dashboard/storefrontQueryClassification" import { bucketKey, emptyTimeline, getAnalyticsWindow, type AnalyticsGrain } from "@/lib/dashboard/analyticsTimeline" import { computeAiRevenueAttribution } from "@/lib/analytics/revenueAttribution" +import { classifyAiResolution, classifyCustomerSentiment } from "@/lib/analytics/aiEvents" +import { parseStorefrontChatActions } from "@/lib/storefront-chat/actions" type StorefrontMessageRow = { + id: string senderType: "CUSTOMER" | "AI" createdAt: Date content: string conversationId: string + actions: unknown } type AnalyticsEventRow = { @@ -58,6 +62,13 @@ type ChatMessageRow = { createdAt: Date } +type TicketLogRow = { + id: string + subject: string + bodyPreview: string | null + sentAt: Date +} + type PrismaWithAnalytics = typeof prisma & { aiAnalyticsEvent: { findMany: (args: { @@ -71,6 +82,184 @@ type PrismaWithAnalytics = typeof prisma & { } } +function analyticsEventKey(event: Pick) { + return [event.eventType, event.messageId ?? "", event.productId ?? "", event.variantId ?? ""].join("|") +} + +function syntheticEvent(params: { + id: string + conversationId?: string | null + messageId?: string | null + eventType: string + productId?: string | null + variantId?: string | null + quantity?: number | null + amount?: number | null + sentiment?: string | null + resolutionStatus?: string | null + metadata?: unknown + createdAt: Date +}): AnalyticsEventRow { + return { + id: params.id, + conversationId: params.conversationId ?? null, + messageId: params.messageId ?? null, + eventType: params.eventType, + source: "derived_from_existing_data", + productId: params.productId ?? null, + variantId: params.variantId ?? null, + quantity: params.quantity ?? null, + amount: params.amount ?? null, + currency: null, + sentiment: params.sentiment ?? null, + resolutionStatus: params.resolutionStatus ?? null, + orderId: null, + attributionType: null, + metadata: params.metadata ?? null, + createdAt: params.createdAt, + } +} + +function deriveAnalyticsEventsFromExistingData(params: { + persistedEvents: AnalyticsEventRow[] + messages: StorefrontMessageRow[] + ticketLogs: TicketLogRow[] +}): AnalyticsEventRow[] { + const existingKeys = new Set(params.persistedEvents.map(analyticsEventKey)) + const derived: AnalyticsEventRow[] = [] + + const pushIfMissing = (event: AnalyticsEventRow) => { + const key = analyticsEventKey(event) + if (existingKeys.has(key)) return + existingKeys.add(key) + derived.push(event) + } + + for (const message of params.messages) { + if (message.senderType === "CUSTOMER") { + pushIfMissing( + syntheticEvent({ + id: `derived-question-${message.id}`, + conversationId: message.conversationId, + messageId: message.id, + eventType: "customer_question", + sentiment: classifyCustomerSentiment(message.content), + metadata: { derivedFrom: "StorefrontChatMessage" }, + createdAt: message.createdAt, + }), + ) + continue + } + + const resolutionStatus = classifyAiResolution({ content: message.content }) + pushIfMissing( + syntheticEvent({ + id: `derived-ai-${message.id}`, + conversationId: message.conversationId, + messageId: message.id, + eventType: "ai_answered", + resolutionStatus, + metadata: { derivedFrom: "StorefrontChatMessage" }, + createdAt: message.createdAt, + }), + ) + if (resolutionStatus === "resolved_by_ai") { + pushIfMissing( + syntheticEvent({ + id: `derived-resolved-${message.id}`, + conversationId: message.conversationId, + messageId: message.id, + eventType: "resolved_by_ai", + resolutionStatus, + metadata: { derivedFrom: "StorefrontChatMessage" }, + createdAt: message.createdAt, + }), + ) + } else if (resolutionStatus === "needs_follow_up") { + pushIfMissing( + syntheticEvent({ + id: `derived-unresolved-${message.id}`, + conversationId: message.conversationId, + messageId: message.id, + eventType: "unresolved_question", + resolutionStatus, + metadata: { derivedFrom: "StorefrontChatMessage" }, + createdAt: message.createdAt, + }), + ) + } + + for (const action of parseStorefrontChatActions(message.actions)) { + if (action.type === "upsell_suggestion") { + pushIfMissing( + syntheticEvent({ + id: `derived-upsell-shown-${message.id}-${action.product.productId}`, + conversationId: message.conversationId, + messageId: message.id, + eventType: "upsell_shown", + productId: action.product.productId, + variantId: action.product.variantId ?? null, + quantity: action.product.quantity ?? 1, + amount: action.product.price ? Number(action.product.price) || null : null, + metadata: { derivedFrom: "StorefrontChatMessage.actions", title: action.product.title }, + createdAt: message.createdAt, + }), + ) + } else if (action.type === "add_to_cart") { + pushIfMissing( + syntheticEvent({ + id: `derived-cart-action-shown-${message.id}-${action.product.productId}`, + conversationId: message.conversationId, + messageId: message.id, + eventType: "cart_action_shown", + productId: action.product.productId, + variantId: action.product.variantId ?? null, + quantity: action.product.quantity ?? 1, + amount: action.product.price ? Number(action.product.price) || null : null, + metadata: { derivedFrom: "StorefrontChatMessage.actions", title: action.product.title }, + createdAt: message.createdAt, + }), + ) + } else if (action.type === "open_cart") { + pushIfMissing( + syntheticEvent({ + id: `derived-cart-link-${message.id}`, + conversationId: message.conversationId, + messageId: message.id, + eventType: "cart_link_generated", + productId: action.product?.productId ?? null, + variantId: action.product?.variantId ?? null, + quantity: action.product?.quantity ?? null, + metadata: { derivedFrom: "StorefrontChatMessage.actions" }, + createdAt: message.createdAt, + }), + ) + } + } + } + + const hasPersistedTicketEvents = params.persistedEvents.some((event) => event.eventType === "ticket_created") + if (!hasPersistedTicketEvents) { + for (const ticket of params.ticketLogs) { + pushIfMissing( + syntheticEvent({ + id: `derived-ticket-${ticket.id}`, + eventType: "ticket_created", + resolutionStatus: "escalated", + metadata: { + derivedFrom: "EmailLog", + subject: ticket.subject, + preview: ticket.bodyPreview, + }, + createdAt: ticket.sentAt, + }), + ) + } + } + + return [...params.persistedEvents, ...derived].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) +} + function median(nums: number[]): number { if (nums.length === 0) return 0 const s = [...nums].sort((a, b) => a - b) @@ -131,12 +320,13 @@ export async function GET(req: Request) { const vendorUserId = store.userId const prismaWithAnalytics = prisma as PrismaWithAnalytics - const [storefrontMessages, analyticsEvents, conversations, cachedOrders, chatMessages]: [ + const [storefrontMessages, persistedAnalyticsEvents, conversations, cachedOrders, chatMessages, ticketLogs]: [ StorefrontMessageRow[], AnalyticsEventRow[], ConversationRow[], CachedOrderRow[], ChatMessageRow[], + TicketLogRow[], ] = await Promise.all([ prisma.storefrontChatMessage.findMany({ @@ -145,10 +335,12 @@ export async function GET(req: Request) { createdAt: { gte: window.since, lte: window.until }, }, select: { + id: true, senderType: true, createdAt: true, content: true, conversationId: true, + actions: true, }, orderBy: [{ conversationId: "asc" }, { createdAt: "asc" }], }), @@ -211,8 +403,28 @@ export async function GET(req: Request) { orderBy: { createdAt: "asc" }, take: 8000, }), + prisma.emailLog.findMany({ + where: { + storeId: store.id, + trigger: "STOREFRONT_TICKET", + sentAt: { gte: window.since, lte: window.until }, + }, + select: { + id: true, + subject: true, + bodyPreview: true, + sentAt: true, + }, + orderBy: { sentAt: "asc" }, + }), ]) + const analyticsEvents = deriveAnalyticsEventsFromExistingData({ + persistedEvents: persistedAnalyticsEvents, + messages: storefrontMessages, + ticketLogs, + }) + const revenueAttribution = computeAiRevenueAttribution({ events: analyticsEvents, conversations, @@ -418,7 +630,8 @@ export async function GET(req: Request) { hiddenByDefault: true, storefrontAiMessages: storefrontMessages.filter((m) => m.senderType === "AI").length, storefrontCustomerMessages: storefrontMessages.filter((m) => m.senderType === "CUSTOMER").length, - analyticsEvents: analyticsEvents.length, + analyticsEvents: persistedAnalyticsEvents.length, + derivedAnalyticsEvents: analyticsEvents.length - persistedAnalyticsEvents.length, cachedOrders: cachedOrders.length, }, totals: {