fix: ai analytics

This commit is contained in:
root 2026-05-12 00:30:41 +05:00
parent c5ed06697e
commit 23ab998311
2 changed files with 289 additions and 2 deletions

View File

@ -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)
})
})

View File

@ -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<AnalyticsEventRow, "eventType" | "messageId" | "productId" | "variantId">) {
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: {