694 lines
28 KiB
TypeScript
694 lines
28 KiB
TypeScript
import { NextResponse } from "next/server"
|
|
import { auth } from "@/auth"
|
|
import { prisma } from "@/lib/prisma"
|
|
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 = {
|
|
id: string
|
|
conversationId: string | null
|
|
messageId: string | null
|
|
eventType: string
|
|
source: string
|
|
productId: string | null
|
|
variantId: string | null
|
|
quantity: number | null
|
|
amount: number | null
|
|
currency: string | null
|
|
sentiment: string | null
|
|
resolutionStatus: string | null
|
|
orderId: string | null
|
|
attributionType: string | null
|
|
metadata: unknown
|
|
createdAt: Date
|
|
}
|
|
|
|
type ConversationRow = {
|
|
id: string
|
|
visitorId: string
|
|
customerEmail: string | null
|
|
boundShopifyOrderId: string | null
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
}
|
|
|
|
type CachedOrderRow = {
|
|
shopifyOrderId: string
|
|
orderNumber: string
|
|
email: string | null
|
|
totalPrice: string
|
|
currency: string | null
|
|
lineItems: unknown
|
|
shopifyCreatedAt: Date | null
|
|
createdAt: Date
|
|
}
|
|
|
|
type ChatMessageRow = {
|
|
senderId: string
|
|
receiverId: string
|
|
createdAt: Date
|
|
}
|
|
|
|
type TicketLogRow = {
|
|
id: string
|
|
subject: string
|
|
bodyPreview: string | null
|
|
sentAt: Date
|
|
}
|
|
|
|
type PrismaWithAnalytics = typeof prisma & {
|
|
aiAnalyticsEvent: {
|
|
findMany: (args: {
|
|
where: {
|
|
storeId: string
|
|
createdAt: { gte: Date; lte: Date }
|
|
}
|
|
select: Record<string, boolean>
|
|
orderBy: { createdAt: "asc" }
|
|
}) => Promise<AnalyticsEventRow[]>
|
|
}
|
|
}
|
|
|
|
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)
|
|
const m = Math.floor(s.length / 2)
|
|
return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2
|
|
}
|
|
|
|
function pct(part: number, total: number): number {
|
|
if (total <= 0) return 0
|
|
return Math.round((part / total) * 1000) / 10
|
|
}
|
|
|
|
function money(amount: number): number {
|
|
return Math.round(amount * 100) / 100
|
|
}
|
|
|
|
function addToTimeline<T extends { key: string }>(
|
|
timeline: T[],
|
|
date: Date,
|
|
grain: AnalyticsGrain,
|
|
update: (row: T) => void,
|
|
) {
|
|
const key = bucketKey(date, grain)
|
|
const row = timeline.find((item) => item.key === key)
|
|
if (row) update(row)
|
|
}
|
|
|
|
function productQuestionLabel(content: string): string {
|
|
return (
|
|
content
|
|
.replace(/[^\w\s-]/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, 70) || "Product question"
|
|
)
|
|
}
|
|
|
|
function incrementProductQuestion(
|
|
rows: Map<string, { name: string; count: number }>,
|
|
name: string,
|
|
) {
|
|
const clean = name.trim()
|
|
if (!clean) return
|
|
rows.set(clean, {
|
|
name: clean,
|
|
count: (rows.get(clean)?.count ?? 0) + 1,
|
|
})
|
|
}
|
|
|
|
function cartEventTypes() {
|
|
return new Set([
|
|
"add_to_cart_clicked",
|
|
"upsell_clicked",
|
|
"cross_sell_clicked",
|
|
"cart_link_opened",
|
|
"cart_link_generated",
|
|
"cart_action_shown",
|
|
])
|
|
}
|
|
|
|
export async function GET(req: Request) {
|
|
try {
|
|
const session = await auth()
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
|
}
|
|
if (session.user.role !== "VENDOR") {
|
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
|
}
|
|
|
|
const store = await getVendorActiveStore(session.user.id)
|
|
if (!store) {
|
|
return NextResponse.json({ message: "No active store" }, { status: 400 })
|
|
}
|
|
|
|
const { searchParams } = new URL(req.url)
|
|
const window = getAnalyticsWindow({
|
|
range: searchParams.get("range"),
|
|
grain: searchParams.get("grain"),
|
|
})
|
|
const vendorUserId = store.userId
|
|
const prismaWithAnalytics = prisma as PrismaWithAnalytics
|
|
|
|
const [storefrontMessages, persistedAnalyticsEvents, conversations, cachedOrders, chatMessages, ticketLogs]: [
|
|
StorefrontMessageRow[],
|
|
AnalyticsEventRow[],
|
|
ConversationRow[],
|
|
CachedOrderRow[],
|
|
ChatMessageRow[],
|
|
TicketLogRow[],
|
|
] =
|
|
await Promise.all([
|
|
prisma.storefrontChatMessage.findMany({
|
|
where: {
|
|
conversation: { storeId: store.id },
|
|
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" }],
|
|
}),
|
|
prismaWithAnalytics.aiAnalyticsEvent.findMany({
|
|
where: { storeId: store.id, createdAt: { gte: window.since, lte: window.until } },
|
|
select: {
|
|
id: true,
|
|
conversationId: true,
|
|
messageId: true,
|
|
eventType: true,
|
|
source: true,
|
|
productId: true,
|
|
variantId: true,
|
|
quantity: true,
|
|
amount: true,
|
|
currency: true,
|
|
sentiment: true,
|
|
resolutionStatus: true,
|
|
orderId: true,
|
|
attributionType: true,
|
|
metadata: true,
|
|
createdAt: true,
|
|
},
|
|
orderBy: { createdAt: "asc" },
|
|
}),
|
|
prisma.storefrontChatConversation.findMany({
|
|
where: { storeId: store.id, updatedAt: { gte: window.since } },
|
|
select: {
|
|
id: true,
|
|
visitorId: true,
|
|
customerEmail: true,
|
|
boundShopifyOrderId: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
},
|
|
orderBy: { updatedAt: "asc" },
|
|
}),
|
|
prisma.cachedOrder.findMany({
|
|
where: {
|
|
storeId: store.id,
|
|
OR: [
|
|
{ shopifyCreatedAt: { gte: window.since, lte: window.until } },
|
|
{ createdAt: { gte: window.since, lte: window.until } },
|
|
],
|
|
},
|
|
select: {
|
|
shopifyOrderId: true,
|
|
orderNumber: true,
|
|
email: true,
|
|
totalPrice: true,
|
|
currency: true,
|
|
lineItems: true,
|
|
shopifyCreatedAt: true,
|
|
createdAt: true,
|
|
},
|
|
}),
|
|
prisma.chatMessage.findMany({
|
|
where: { storeId: store.id, createdAt: { gte: window.since, lte: window.until } },
|
|
select: { senderId: true, receiverId: true, createdAt: true },
|
|
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,
|
|
orders: cachedOrders,
|
|
})
|
|
|
|
const timeline = emptyTimeline(window.labels, window.grain, {
|
|
directUpsellRevenue: 0,
|
|
directCrossSellRevenue: 0,
|
|
aiAssistedRevenue: 0,
|
|
questions: 0,
|
|
resolvedByAi: 0,
|
|
unresolvedQuestions: 0,
|
|
ticketsCreated: 0,
|
|
upsellShown: 0,
|
|
upsellClicked: 0,
|
|
cartActions: 0,
|
|
positiveSentiment: 0,
|
|
neutralSentiment: 0,
|
|
negativeSentiment: 0,
|
|
urgentSentiment: 0,
|
|
})
|
|
|
|
for (const event of analyticsEvents) {
|
|
addToTimeline(timeline, event.createdAt, window.grain, (row) => {
|
|
if (event.eventType === "customer_question") row.questions += 1
|
|
if (event.eventType === "resolved_by_ai") row.resolvedByAi += 1
|
|
if (event.eventType === "unresolved_question") row.unresolvedQuestions += 1
|
|
if (event.eventType === "ticket_created") row.ticketsCreated += 1
|
|
if (event.eventType === "upsell_shown") row.upsellShown += 1
|
|
if (event.eventType === "upsell_clicked") row.upsellClicked += 1
|
|
if (cartEventTypes().has(event.eventType)) row.cartActions += 1
|
|
if (event.sentiment === "positive") row.positiveSentiment += 1
|
|
if (event.sentiment === "neutral") row.neutralSentiment += 1
|
|
if (event.sentiment === "negative") row.negativeSentiment += 1
|
|
if (event.sentiment === "urgent") row.urgentSentiment += 1
|
|
})
|
|
}
|
|
for (const row of revenueAttribution.directRows) {
|
|
addToTimeline(timeline, row.createdAt, window.grain, (bucket) => {
|
|
if (row.attributionType === "direct_upsell") bucket.directUpsellRevenue += row.amount
|
|
if (row.attributionType === "direct_cross_sell") bucket.directCrossSellRevenue += row.amount
|
|
})
|
|
}
|
|
for (const row of revenueAttribution.assistedRows) {
|
|
addToTimeline(timeline, row.createdAt, window.grain, (bucket) => {
|
|
bucket.aiAssistedRevenue += row.amount
|
|
})
|
|
}
|
|
for (const row of timeline) {
|
|
row.directUpsellRevenue = money(row.directUpsellRevenue)
|
|
row.directCrossSellRevenue = money(row.directCrossSellRevenue)
|
|
row.aiAssistedRevenue = money(row.aiAssistedRevenue)
|
|
}
|
|
|
|
const intentCounts: Record<string, number> = {
|
|
WISMO: 0,
|
|
Returns: 0,
|
|
"Product Qs": 0,
|
|
Shipping: 0,
|
|
General: 0,
|
|
}
|
|
const productQuestionCounts = new Map<string, { name: string; count: number }>()
|
|
const pendingByConversation = new Map<string, Date>()
|
|
const aiLatencySeconds: number[] = []
|
|
|
|
for (const message of storefrontMessages) {
|
|
if (message.senderType === "CUSTOMER") {
|
|
const bucket = classifyStorefrontCustomerQuery(message.content)
|
|
intentCounts[bucket] += 1
|
|
if (bucket === "Product Qs") {
|
|
incrementProductQuestion(productQuestionCounts, productQuestionLabel(message.content))
|
|
}
|
|
pendingByConversation.set(message.conversationId, message.createdAt)
|
|
} else if (message.senderType === "AI") {
|
|
for (const action of parseStorefrontChatActions(message.actions)) {
|
|
if (action.type === "add_to_cart" || action.type === "upsell_suggestion") {
|
|
incrementProductQuestion(productQuestionCounts, action.product.title)
|
|
}
|
|
if (action.type === "open_cart" && action.product?.title) {
|
|
incrementProductQuestion(productQuestionCounts, action.product.title)
|
|
}
|
|
}
|
|
const pendingAt = pendingByConversation.get(message.conversationId)
|
|
if (!pendingAt) continue
|
|
const sec = (message.createdAt.getTime() - pendingAt.getTime()) / 1000
|
|
if (sec >= 0 && sec <= 3600) aiLatencySeconds.push(sec)
|
|
pendingByConversation.delete(message.conversationId)
|
|
}
|
|
}
|
|
|
|
const manualLatencyMinutes: number[] = []
|
|
const pendingManualByCounterparty = new Map<string, Date>()
|
|
for (const message of chatMessages) {
|
|
if (message.receiverId === vendorUserId && message.senderId !== vendorUserId) {
|
|
pendingManualByCounterparty.set(message.senderId, message.createdAt)
|
|
continue
|
|
}
|
|
if (message.senderId === vendorUserId) {
|
|
const pendingAt = pendingManualByCounterparty.get(message.receiverId)
|
|
if (!pendingAt) continue
|
|
const min = (message.createdAt.getTime() - pendingAt.getTime()) / 60_000
|
|
if (min >= 0 && min <= 8 * 60) manualLatencyMinutes.push(min)
|
|
pendingManualByCounterparty.delete(message.receiverId)
|
|
}
|
|
}
|
|
|
|
const questions =
|
|
analyticsEvents.filter((event) => event.eventType === "customer_question").length ||
|
|
storefrontMessages.filter((m) => m.senderType === "CUSTOMER").length
|
|
const aiAnswers =
|
|
analyticsEvents.filter((event) => event.eventType === "ai_answered").length ||
|
|
storefrontMessages.filter((m) => m.senderType === "AI").length
|
|
const resolvedByAi = analyticsEvents.filter((event) => event.eventType === "resolved_by_ai").length
|
|
const unresolvedQuestions = analyticsEvents.filter((event) => event.eventType === "unresolved_question").length
|
|
const ticketsCreated = analyticsEvents.filter((event) => event.eventType === "ticket_created").length
|
|
const cartTypes = cartEventTypes()
|
|
const cartEvents = analyticsEvents.filter((event) => cartTypes.has(event.eventType))
|
|
const cartClicks = cartEvents.length
|
|
const cartOpenCount = analyticsEvents.filter((event) => event.eventType === "cart_link_opened").length
|
|
const directOrderCount = new Set(revenueAttribution.directRows.map((row) => row.orderId)).size
|
|
const cartConversationIds = new Set(
|
|
cartEvents.map((event) => event.conversationId).filter((id): id is string => Boolean(id)),
|
|
)
|
|
const cartAttributedOrderCount = new Set(
|
|
revenueAttribution.assistedRows
|
|
.filter((row) => cartConversationIds.has(row.conversationId))
|
|
.map((row) => row.orderId),
|
|
).size
|
|
const conversionBase = cartConversationIds.size || cartClicks
|
|
const currency = cachedOrders[0]?.currency ?? revenueAttribution.directRows[0]?.currency ?? "GBP"
|
|
|
|
const sentimentTotals = {
|
|
positive: analyticsEvents.filter((event) => event.sentiment === "positive").length,
|
|
neutral: analyticsEvents.filter((event) => event.sentiment === "neutral").length,
|
|
negative: analyticsEvents.filter((event) => event.sentiment === "negative").length,
|
|
urgent: analyticsEvents.filter((event) => event.sentiment === "urgent").length,
|
|
unknown: analyticsEvents.filter((event) => event.sentiment === "unknown").length,
|
|
}
|
|
const sentimentTotal = Object.values(sentimentTotals).reduce((sum, count) => sum + count, 0)
|
|
const aiMedianSeconds = median(aiLatencySeconds)
|
|
const manualMedianMinutes = median(manualLatencyMinutes)
|
|
const manualBenchmarkMinutes = manualLatencyMinutes.length > 0 ? manualMedianMinutes : 3
|
|
const minutesSavedPerAnswer = Math.min(
|
|
10,
|
|
Math.max(0, manualBenchmarkMinutes - aiMedianSeconds / 60),
|
|
)
|
|
const hoursSavedEstimate = Math.round((minutesSavedPerAnswer * aiAnswers / 60) * 10) / 10
|
|
const topProductQuestions = Array.from(productQuestionCounts.values())
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, 8)
|
|
const convertedProductIds = new Set(revenueAttribution.directRows.map((row) => row.productId).filter(Boolean))
|
|
const highInterestProducts = topProductQuestions
|
|
.filter((row) => !convertedProductIds.has(row.name))
|
|
.slice(0, 5)
|
|
|
|
return NextResponse.json({
|
|
storeId: store.id,
|
|
generatedAt: new Date().toISOString(),
|
|
range: window.range,
|
|
grain: window.grain,
|
|
window: {
|
|
since: window.since.toISOString(),
|
|
until: window.until.toISOString(),
|
|
},
|
|
revenueImpact: {
|
|
currency,
|
|
directUpsellRevenue: money(revenueAttribution.directUpsellRevenue),
|
|
directCrossSellRevenue: money(revenueAttribution.directCrossSellRevenue),
|
|
aiAssistedRevenue: money(revenueAttribution.aiAssistedRevenue),
|
|
aiCartConversionRate: pct(cartAttributedOrderCount || directOrderCount, conversionBase),
|
|
averageAiInfluencedOrderValue:
|
|
revenueAttribution.assistedRows.length > 0
|
|
? money(revenueAttribution.aiAssistedRevenue / revenueAttribution.assistedRows.length)
|
|
: 0,
|
|
pendingAttributionCount: Math.max(0, conversionBase - (cartAttributedOrderCount || directOrderCount)),
|
|
directRows: revenueAttribution.directRows.slice(0, 12).map((row) => ({
|
|
...row,
|
|
createdAt: row.createdAt.toISOString(),
|
|
})),
|
|
assistedRows: revenueAttribution.assistedRows.slice(0, 12).map((row) => ({
|
|
...row,
|
|
createdAt: row.createdAt.toISOString(),
|
|
})),
|
|
methodology:
|
|
"Direct upsell/cross-sell revenue only counts clicked AI-suggested products later found in matching orders. Cart conversion counts cart-engaged conversations that later match a cached Shopify order.",
|
|
},
|
|
customerExperience: {
|
|
sentimentMix: {
|
|
totals: sentimentTotals,
|
|
percentages: {
|
|
positive: pct(sentimentTotals.positive, sentimentTotal),
|
|
neutral: pct(sentimentTotals.neutral, sentimentTotal),
|
|
negative: pct(sentimentTotals.negative, sentimentTotal),
|
|
urgent: pct(sentimentTotals.urgent, sentimentTotal),
|
|
unknown: pct(sentimentTotals.unknown, sentimentTotal),
|
|
},
|
|
},
|
|
unresolvedQuestionRate: pct(unresolvedQuestions, questions),
|
|
escalationRate: pct(ticketsCreated, questions),
|
|
unresolvedQuestions,
|
|
ticketsCreated,
|
|
negativeOrUrgent: sentimentTotals.negative + sentimentTotals.urgent,
|
|
},
|
|
automationOutcomes: {
|
|
questionsAnswered: questions,
|
|
aiAnswers,
|
|
resolvedByAi,
|
|
ticketsCreated,
|
|
aiMedianSeconds: Math.round(aiMedianSeconds),
|
|
manualMedianMinutes: Math.round(manualBenchmarkMinutes * 10) / 10,
|
|
sampleAiPairs: aiLatencySeconds.length,
|
|
sampleManualPairs: manualLatencyMinutes.length,
|
|
hoursSavedEstimate,
|
|
resolutionRate: pct(resolvedByAi, questions),
|
|
},
|
|
demandSignals: {
|
|
curiousProductQuestionCount: intentCounts["Product Qs"],
|
|
topProductQuestions,
|
|
highInterestProducts,
|
|
questionMix: Object.entries(intentCounts).map(([name, count]) => ({ name, count })),
|
|
},
|
|
timeline,
|
|
technicalDiagnostics: {
|
|
hiddenByDefault: true,
|
|
storefrontAiMessages: storefrontMessages.filter((m) => m.senderType === "AI").length,
|
|
storefrontCustomerMessages: storefrontMessages.filter((m) => m.senderType === "CUSTOMER").length,
|
|
analyticsEvents: persistedAnalyticsEvents.length,
|
|
derivedAnalyticsEvents: analyticsEvents.length - persistedAnalyticsEvents.length,
|
|
cachedOrders: cachedOrders.length,
|
|
},
|
|
totals: {
|
|
questions,
|
|
aiAnswers,
|
|
cartClicks,
|
|
cartOpenCount,
|
|
},
|
|
})
|
|
} catch (e) {
|
|
console.error("[ai-analytics]", e)
|
|
return NextResponse.json({ message: "Failed to load analytics" }, { status: 500 })
|
|
}
|
|
}
|