fix: ai analytics
This commit is contained in:
parent
c5ed06697e
commit
23ab998311
@ -16,6 +16,7 @@ vi.mock("@/lib/prisma", () => ({
|
|||||||
storefrontChatConversation: { findMany: vi.fn() },
|
storefrontChatConversation: { findMany: vi.fn() },
|
||||||
cachedOrder: { findMany: vi.fn() },
|
cachedOrder: { findMany: vi.fn() },
|
||||||
chatMessage: { 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(getVendorActiveStore).mockResolvedValue({ id: "store-1", userId: "user-1" } as never)
|
||||||
vi.mocked(prisma.storefrontChatMessage.findMany).mockResolvedValue([
|
vi.mocked(prisma.storefrontChatMessage.findMany).mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
id: "m1",
|
||||||
senderType: "CUSTOMER",
|
senderType: "CUSTOMER",
|
||||||
createdAt: new Date("2026-05-01T10:00:00Z"),
|
createdAt: new Date("2026-05-01T10:00:00Z"),
|
||||||
content: "Do you have school shoes?",
|
content: "Do you have school shoes?",
|
||||||
conversationId: "conv-1",
|
conversationId: "conv-1",
|
||||||
|
actions: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "m2",
|
||||||
senderType: "AI",
|
senderType: "AI",
|
||||||
createdAt: new Date("2026-05-01T10:00:03Z"),
|
createdAt: new Date("2026-05-01T10:00:03Z"),
|
||||||
content: "Yes, we have school shoes.",
|
content: "Yes, we have school shoes.",
|
||||||
conversationId: "conv-1",
|
conversationId: "conv-1",
|
||||||
|
actions: null,
|
||||||
},
|
},
|
||||||
] as never)
|
] as never)
|
||||||
vi.mocked(prisma.aiAnalyticsEvent.findMany).mockResolvedValue([
|
vi.mocked(prisma.aiAnalyticsEvent.findMany).mockResolvedValue([
|
||||||
@ -95,6 +100,7 @@ describe("GET /api/dashboard/ai-analytics", () => {
|
|||||||
] as never)
|
] as never)
|
||||||
vi.mocked(prisma.cachedOrder.findMany).mockResolvedValue([] as never)
|
vi.mocked(prisma.cachedOrder.findMany).mockResolvedValue([] as never)
|
||||||
vi.mocked(prisma.chatMessage.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 res = await GET(new Request("http://localhost/api/dashboard/ai-analytics?range=30d"))
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
@ -109,4 +115,72 @@ describe("GET /api/dashboard/ai-analytics", () => {
|
|||||||
expect(json.automationOutcomes.questionsAnswered).toBe(1)
|
expect(json.automationOutcomes.questionsAnswered).toBe(1)
|
||||||
expect(Array.isArray(json.timeline)).toBe(true)
|
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 { classifyStorefrontCustomerQuery } from "@/lib/dashboard/storefrontQueryClassification"
|
||||||
import { bucketKey, emptyTimeline, getAnalyticsWindow, type AnalyticsGrain } from "@/lib/dashboard/analyticsTimeline"
|
import { bucketKey, emptyTimeline, getAnalyticsWindow, type AnalyticsGrain } from "@/lib/dashboard/analyticsTimeline"
|
||||||
import { computeAiRevenueAttribution } from "@/lib/analytics/revenueAttribution"
|
import { computeAiRevenueAttribution } from "@/lib/analytics/revenueAttribution"
|
||||||
|
import { classifyAiResolution, classifyCustomerSentiment } from "@/lib/analytics/aiEvents"
|
||||||
|
import { parseStorefrontChatActions } from "@/lib/storefront-chat/actions"
|
||||||
|
|
||||||
type StorefrontMessageRow = {
|
type StorefrontMessageRow = {
|
||||||
|
id: string
|
||||||
senderType: "CUSTOMER" | "AI"
|
senderType: "CUSTOMER" | "AI"
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
content: string
|
content: string
|
||||||
conversationId: string
|
conversationId: string
|
||||||
|
actions: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalyticsEventRow = {
|
type AnalyticsEventRow = {
|
||||||
@ -58,6 +62,13 @@ type ChatMessageRow = {
|
|||||||
createdAt: Date
|
createdAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TicketLogRow = {
|
||||||
|
id: string
|
||||||
|
subject: string
|
||||||
|
bodyPreview: string | null
|
||||||
|
sentAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
type PrismaWithAnalytics = typeof prisma & {
|
type PrismaWithAnalytics = typeof prisma & {
|
||||||
aiAnalyticsEvent: {
|
aiAnalyticsEvent: {
|
||||||
findMany: (args: {
|
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 {
|
function median(nums: number[]): number {
|
||||||
if (nums.length === 0) return 0
|
if (nums.length === 0) return 0
|
||||||
const s = [...nums].sort((a, b) => a - b)
|
const s = [...nums].sort((a, b) => a - b)
|
||||||
@ -131,12 +320,13 @@ export async function GET(req: Request) {
|
|||||||
const vendorUserId = store.userId
|
const vendorUserId = store.userId
|
||||||
const prismaWithAnalytics = prisma as PrismaWithAnalytics
|
const prismaWithAnalytics = prisma as PrismaWithAnalytics
|
||||||
|
|
||||||
const [storefrontMessages, analyticsEvents, conversations, cachedOrders, chatMessages]: [
|
const [storefrontMessages, persistedAnalyticsEvents, conversations, cachedOrders, chatMessages, ticketLogs]: [
|
||||||
StorefrontMessageRow[],
|
StorefrontMessageRow[],
|
||||||
AnalyticsEventRow[],
|
AnalyticsEventRow[],
|
||||||
ConversationRow[],
|
ConversationRow[],
|
||||||
CachedOrderRow[],
|
CachedOrderRow[],
|
||||||
ChatMessageRow[],
|
ChatMessageRow[],
|
||||||
|
TicketLogRow[],
|
||||||
] =
|
] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.storefrontChatMessage.findMany({
|
prisma.storefrontChatMessage.findMany({
|
||||||
@ -145,10 +335,12 @@ export async function GET(req: Request) {
|
|||||||
createdAt: { gte: window.since, lte: window.until },
|
createdAt: { gte: window.since, lte: window.until },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
|
id: true,
|
||||||
senderType: true,
|
senderType: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
content: true,
|
content: true,
|
||||||
conversationId: true,
|
conversationId: true,
|
||||||
|
actions: true,
|
||||||
},
|
},
|
||||||
orderBy: [{ conversationId: "asc" }, { createdAt: "asc" }],
|
orderBy: [{ conversationId: "asc" }, { createdAt: "asc" }],
|
||||||
}),
|
}),
|
||||||
@ -211,8 +403,28 @@ export async function GET(req: Request) {
|
|||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
take: 8000,
|
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({
|
const revenueAttribution = computeAiRevenueAttribution({
|
||||||
events: analyticsEvents,
|
events: analyticsEvents,
|
||||||
conversations,
|
conversations,
|
||||||
@ -418,7 +630,8 @@ export async function GET(req: Request) {
|
|||||||
hiddenByDefault: true,
|
hiddenByDefault: true,
|
||||||
storefrontAiMessages: storefrontMessages.filter((m) => m.senderType === "AI").length,
|
storefrontAiMessages: storefrontMessages.filter((m) => m.senderType === "AI").length,
|
||||||
storefrontCustomerMessages: storefrontMessages.filter((m) => m.senderType === "CUSTOMER").length,
|
storefrontCustomerMessages: storefrontMessages.filter((m) => m.senderType === "CUSTOMER").length,
|
||||||
analyticsEvents: analyticsEvents.length,
|
analyticsEvents: persistedAnalyticsEvents.length,
|
||||||
|
derivedAnalyticsEvents: analyticsEvents.length - persistedAnalyticsEvents.length,
|
||||||
cachedOrders: cachedOrders.length,
|
cachedOrders: cachedOrders.length,
|
||||||
},
|
},
|
||||||
totals: {
|
totals: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user