@@ -1,12 +1,13 @@
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { PublicTypebot } from '@typebot.io/schemas'
|
||||
import { z } from 'zod'
|
||||
import { canReadTypebots } from '@/helpers/databaseRules'
|
||||
import { totalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
|
||||
import { totalAnswersSchema } from '@typebot.io/schemas/features/analytics'
|
||||
import { parseGroups } from '@typebot.io/schemas'
|
||||
import { isInputBlock } from '@typebot.io/lib'
|
||||
|
||||
export const getTotalAnswersInBlocks = authenticatedProcedure
|
||||
export const getTotalAnswers = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
@@ -21,7 +22,7 @@ export const getTotalAnswersInBlocks = authenticatedProcedure
|
||||
typebotId: z.string(),
|
||||
})
|
||||
)
|
||||
.output(z.object({ totalAnswersInBlocks: z.array(totalAnswersInBlock) }))
|
||||
.output(z.object({ totalAnswers: z.array(totalAnswersSchema) }))
|
||||
.query(async ({ input: { typebotId }, ctx: { user } }) => {
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: canReadTypebots(typebotId, user),
|
||||
@@ -33,17 +34,17 @@ export const getTotalAnswersInBlocks = authenticatedProcedure
|
||||
message: 'Published typebot not found',
|
||||
})
|
||||
|
||||
const publishedTypebot = typebot.publishedTypebot as PublicTypebot
|
||||
|
||||
const totalAnswersPerBlock = await prisma.answer.groupBy({
|
||||
by: ['itemId', 'blockId'],
|
||||
by: ['blockId'],
|
||||
where: {
|
||||
result: {
|
||||
typebotId: typebot.publishedTypebot.typebotId,
|
||||
},
|
||||
blockId: {
|
||||
in: publishedTypebot.groups.flatMap((group) =>
|
||||
group.blocks.map((block) => block.id)
|
||||
in: parseGroups(typebot.publishedTypebot.groups, {
|
||||
typebotVersion: typebot.publishedTypebot.version,
|
||||
}).flatMap((group) =>
|
||||
group.blocks.filter(isInputBlock).map((block) => block.id)
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -51,10 +52,9 @@ export const getTotalAnswersInBlocks = authenticatedProcedure
|
||||
})
|
||||
|
||||
return {
|
||||
totalAnswersInBlocks: totalAnswersPerBlock.map((answer) => ({
|
||||
blockId: answer.blockId,
|
||||
itemId: answer.itemId ?? undefined,
|
||||
total: answer._count._all,
|
||||
totalAnswers: totalAnswersPerBlock.map((a) => ({
|
||||
blockId: a.blockId,
|
||||
total: a._count._all,
|
||||
})),
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { canReadTypebots } from '@/helpers/databaseRules'
|
||||
import { totalVisitedEdgesSchema } from '@typebot.io/schemas'
|
||||
|
||||
export const getTotalVisitedEdges = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/typebots/{typebotId}/analytics/totalVisitedEdges',
|
||||
protect: true,
|
||||
summary: 'List total edges used in results',
|
||||
tags: ['Analytics'],
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
typebotId: z.string(),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
totalVisitedEdges: z.array(totalVisitedEdgesSchema),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { typebotId }, ctx: { user } }) => {
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: canReadTypebots(typebotId, user),
|
||||
select: { id: true },
|
||||
})
|
||||
if (!typebot?.id)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Published typebot not found',
|
||||
})
|
||||
|
||||
const edges = await prisma.visitedEdge.groupBy({
|
||||
by: ['edgeId'],
|
||||
where: {
|
||||
result: {
|
||||
typebotId: typebot.id,
|
||||
},
|
||||
},
|
||||
_count: { resultId: true },
|
||||
})
|
||||
|
||||
return {
|
||||
totalVisitedEdges: edges.map((e) => ({
|
||||
edgeId: e.edgeId,
|
||||
total: e._count.resultId,
|
||||
})),
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import { router } from '@/helpers/server/trpc'
|
||||
import { getTotalAnswersInBlocks } from './getTotalAnswersInBlocks'
|
||||
import { getTotalAnswers } from './getTotalAnswers'
|
||||
import { getTotalVisitedEdges } from './getTotalVisitedEdges'
|
||||
|
||||
export const analyticsRouter = router({
|
||||
getTotalAnswersInBlocks,
|
||||
getTotalAnswers,
|
||||
getTotalVisitedEdges,
|
||||
})
|
||||
|
||||
@@ -15,20 +15,25 @@ import { GroupsCoordinatesProvider } from '@/features/graph/providers/GroupsCoor
|
||||
import { useTranslate } from '@tolgee/react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider'
|
||||
|
||||
export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
|
||||
const { t } = useTranslate()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { typebot, publishedTypebot } = useTypebot()
|
||||
const { data } = trpc.analytics.getTotalAnswersInBlocks.useQuery(
|
||||
const { data } = trpc.analytics.getTotalAnswers.useQuery(
|
||||
{
|
||||
typebotId: typebot?.id as string,
|
||||
},
|
||||
{ enabled: isDefined(publishedTypebot) }
|
||||
)
|
||||
|
||||
const { data: edgesData } = trpc.analytics.getTotalVisitedEdges.useQuery(
|
||||
{
|
||||
typebotId: typebot?.id as string,
|
||||
},
|
||||
{ enabled: isDefined(publishedTypebot) }
|
||||
)
|
||||
const startBlockId = publishedTypebot?.groups
|
||||
.find((group) => group.blocks.at(0)?.type === 'start')
|
||||
?.blocks.at(0)?.id
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -44,28 +49,18 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
|
||||
h="full"
|
||||
justifyContent="center"
|
||||
>
|
||||
{publishedTypebot &&
|
||||
data?.totalAnswersInBlocks &&
|
||||
stats &&
|
||||
startBlockId ? (
|
||||
{publishedTypebot && stats ? (
|
||||
<GraphProvider isReadOnly>
|
||||
<GroupsCoordinatesProvider groups={publishedTypebot?.groups}>
|
||||
<Graph
|
||||
flex="1"
|
||||
typebot={publishedTypebot}
|
||||
onUnlockProPlanClick={onOpen}
|
||||
totalAnswersInBlocks={
|
||||
startBlockId
|
||||
? [
|
||||
{
|
||||
blockId: startBlockId,
|
||||
total: stats.totalViews,
|
||||
},
|
||||
...data.totalAnswersInBlocks,
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<EventsCoordinatesProvider events={publishedTypebot?.events}>
|
||||
<Graph
|
||||
flex="1"
|
||||
typebot={publishedTypebot}
|
||||
onUnlockProPlanClick={onOpen}
|
||||
totalAnswers={data?.totalAnswers}
|
||||
totalVisitedEdges={edgesData?.totalVisitedEdges}
|
||||
/>
|
||||
</EventsCoordinatesProvider>
|
||||
</GroupsCoordinatesProvider>
|
||||
</GraphProvider>
|
||||
) : (
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { isInputBlock } from '@typebot.io/lib'
|
||||
import { PublicTypebot } from '@typebot.io/schemas'
|
||||
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
|
||||
|
||||
export const computePreviousTotalAnswers = (
|
||||
publishedTypebot: PublicTypebot,
|
||||
currentBlockId: string,
|
||||
totalAnswersInBlocks: TotalAnswersInBlock[],
|
||||
visitedBlocks: string[] = []
|
||||
): number => {
|
||||
let totalAnswers = 0
|
||||
const allBlocks = publishedTypebot.groups.flatMap((group) => group.blocks)
|
||||
const currentGroup = publishedTypebot.groups.find((group) =>
|
||||
group.blocks.find((block) => block.id === currentBlockId)
|
||||
)
|
||||
if (!currentGroup) return 0
|
||||
const currentBlockIndex = currentGroup.blocks.findIndex(
|
||||
(block) => block.id === currentBlockId
|
||||
)
|
||||
const previousBlocks = currentGroup.blocks.slice(0, currentBlockIndex + 1)
|
||||
for (const block of previousBlocks.reverse()) {
|
||||
if (visitedBlocks.includes(block.id)) continue
|
||||
if (
|
||||
currentBlockId !== block.id &&
|
||||
(isInputBlock(block) || block.type === 'start')
|
||||
) {
|
||||
visitedBlocks.push(block.id)
|
||||
return (
|
||||
totalAnswersInBlocks.find(
|
||||
(totalAnswersInBlock) =>
|
||||
totalAnswersInBlock.blockId === block.id &&
|
||||
totalAnswersInBlock.itemId === undefined
|
||||
)?.total ?? 0
|
||||
)
|
||||
}
|
||||
const connectedEdges = publishedTypebot.edges.filter(
|
||||
(edge) => edge.to.blockId === block.id
|
||||
)
|
||||
if (connectedEdges.length) {
|
||||
for (const connectedEdge of connectedEdges) {
|
||||
const connectedBlock = allBlocks.find(
|
||||
(block) => block.id === connectedEdge.from.blockId
|
||||
)
|
||||
if (connectedBlock && !visitedBlocks.includes(connectedBlock.id)) {
|
||||
if (isInputBlock(connectedBlock) || connectedBlock.type === 'start') {
|
||||
visitedBlocks.push(connectedBlock.id)
|
||||
totalAnswers +=
|
||||
totalAnswersInBlocks.find(
|
||||
(totalAnswersInBlock) =>
|
||||
totalAnswersInBlock.blockId === connectedEdge.from.blockId &&
|
||||
totalAnswersInBlock.itemId === connectedEdge.from.itemId
|
||||
)?.total ?? 0
|
||||
} else {
|
||||
totalAnswers += computePreviousTotalAnswers(
|
||||
publishedTypebot,
|
||||
connectedBlock.id,
|
||||
totalAnswersInBlocks,
|
||||
visitedBlocks
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const edgesConnectedToGroup = publishedTypebot.edges.filter(
|
||||
(edge) => edge.to.groupId === currentGroup.id
|
||||
)
|
||||
|
||||
if (edgesConnectedToGroup.length) {
|
||||
for (const connectedEdge of edgesConnectedToGroup) {
|
||||
const connectedBlock = allBlocks.find(
|
||||
(block) => block.id === connectedEdge.from.blockId
|
||||
)
|
||||
if (connectedBlock && !visitedBlocks.includes(connectedBlock.id)) {
|
||||
if (isInputBlock(connectedBlock) || connectedBlock.type === 'start') {
|
||||
visitedBlocks.push(connectedBlock.id)
|
||||
totalAnswers +=
|
||||
totalAnswersInBlocks.find(
|
||||
(totalAnswersInBlock) =>
|
||||
totalAnswersInBlock.blockId === connectedEdge.from.blockId &&
|
||||
totalAnswersInBlock.itemId === connectedEdge.from.itemId
|
||||
)?.total ?? 0
|
||||
} else {
|
||||
totalAnswers += computePreviousTotalAnswers(
|
||||
publishedTypebot,
|
||||
connectedBlock.id,
|
||||
totalAnswersInBlocks,
|
||||
visitedBlocks
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalAnswers
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { isInputBlock, isNotDefined } from '@typebot.io/lib'
|
||||
import { PublicTypebotV6 } from '@typebot.io/schemas'
|
||||
import {
|
||||
TotalAnswers,
|
||||
TotalVisitedEdges,
|
||||
} from '@typebot.io/schemas/features/analytics'
|
||||
|
||||
export const computeTotalUsersAtBlock = (
|
||||
currentBlockId: string,
|
||||
{
|
||||
publishedTypebot,
|
||||
totalVisitedEdges,
|
||||
totalAnswers,
|
||||
}: {
|
||||
publishedTypebot: PublicTypebotV6
|
||||
totalVisitedEdges: TotalVisitedEdges[]
|
||||
totalAnswers: TotalAnswers[]
|
||||
}
|
||||
): number => {
|
||||
let totalUsers = 0
|
||||
const currentGroup = publishedTypebot.groups.find((group) =>
|
||||
group.blocks.find((block) => block.id === currentBlockId)
|
||||
)
|
||||
if (!currentGroup) return 0
|
||||
const currentBlockIndex = currentGroup.blocks.findIndex(
|
||||
(block) => block.id === currentBlockId
|
||||
)
|
||||
const previousBlocks = currentGroup.blocks.slice(0, currentBlockIndex + 1)
|
||||
for (const block of previousBlocks.reverse()) {
|
||||
if (currentBlockId !== block.id && isInputBlock(block))
|
||||
return totalAnswers.find((t) => t.blockId === block.id)?.total ?? 0
|
||||
const incomingEdges = publishedTypebot.edges.filter(
|
||||
(edge) => edge.to.blockId === block.id
|
||||
)
|
||||
if (!incomingEdges.length) continue
|
||||
totalUsers += incomingEdges.reduce(
|
||||
(acc, incomingEdge) =>
|
||||
acc +
|
||||
(totalVisitedEdges.find(
|
||||
(totalEdge) => totalEdge.edgeId === incomingEdge.id
|
||||
)?.total ?? 0),
|
||||
0
|
||||
)
|
||||
}
|
||||
const edgesConnectedToGroup = publishedTypebot.edges.filter(
|
||||
(edge) =>
|
||||
edge.to.groupId === currentGroup.id && isNotDefined(edge.to.blockId)
|
||||
)
|
||||
|
||||
totalUsers += edgesConnectedToGroup.reduce(
|
||||
(acc, connectedEdge) =>
|
||||
acc +
|
||||
(totalVisitedEdges.find(
|
||||
(totalEdge) => totalEdge.edgeId === connectedEdge.id
|
||||
)?.total ?? 0),
|
||||
0
|
||||
)
|
||||
|
||||
return totalUsers
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { byId } from '@typebot.io/lib'
|
||||
import { PublicTypebotV6 } from '@typebot.io/schemas'
|
||||
import { TotalAnswers } from '@typebot.io/schemas/features/analytics'
|
||||
|
||||
export const getTotalAnswersAtBlock = (
|
||||
currentBlockId: string,
|
||||
{
|
||||
publishedTypebot,
|
||||
totalAnswers,
|
||||
}: {
|
||||
publishedTypebot: PublicTypebotV6
|
||||
totalAnswers: TotalAnswers[]
|
||||
}
|
||||
): number => {
|
||||
const block = publishedTypebot.groups
|
||||
.flatMap((g) => g.blocks)
|
||||
.find(byId(currentBlockId))
|
||||
if (!block) throw new Error(`Block ${currentBlockId} not found`)
|
||||
return totalAnswers.find((t) => t.blockId === block.id)?.total ?? 0
|
||||
}
|
||||
Reference in New Issue
Block a user