fix: ai analytics
This commit is contained in:
parent
c5ed06697e
commit
23ab998311
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user