2
0

🚸 (results) Add time filter to results table as…

This commit is contained in:
Baptiste Arnaud
2024-02-06 17:48:31 +01:00
parent 3e2533b934
commit 066fabce06
20 changed files with 376 additions and 67 deletions

View File

@ -79,7 +79,7 @@ export const DropdownList = <T extends Item>({
)} )}
</FormLabel> </FormLabel>
)} )}
<Menu isLazy placement="bottom-end" matchWidth> <Menu isLazy>
<MenuButton <MenuButton
as={Button} as={Button}
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />} rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}

View File

@ -213,9 +213,9 @@ export const VariableSearchInput = ({
<Popover <Popover
isOpen={isOpen} isOpen={isOpen}
initialFocusRef={inputRef} initialFocusRef={inputRef}
matchWidth
isLazy isLazy
offset={[0, 2]} offset={[0, 2]}
placement="auto-start"
> >
<PopoverAnchor> <PopoverAnchor>
<Input <Input
@ -239,6 +239,7 @@ export const VariableSearchInput = ({
shadow="lg" shadow="lg"
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
minW="250px"
> >
{isCreateVariableButtonDisplayed && ( {isCreateVariableButtonDisplayed && (
<Button <Button

View File

@ -0,0 +1,89 @@
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 { Stats, statsSchema } from '@typebot.io/schemas'
import { defaultTimeFilter, timeFilterValues } from '../constants'
import { parseDateFromTimeFilter } from '../helpers/parseDateFromTimeFilter'
export const getStats = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/v1/typebots/{typebotId}/analytics/stats',
protect: true,
summary: 'Get results stats',
tags: ['Analytics'],
},
})
.input(
z.object({
typebotId: z.string(),
timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter),
})
)
.output(z.object({ stats: statsSchema }))
.query(async ({ input: { typebotId, timeFilter }, ctx: { user } }) => {
const typebot = await prisma.typebot.findFirst({
where: canReadTypebots(typebotId, user),
select: { publishedTypebot: true, id: true },
})
if (!typebot?.publishedTypebot)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Published typebot not found',
})
const date = parseDateFromTimeFilter(timeFilter)
const [totalViews, totalStarts, totalCompleted] = await prisma.$transaction(
[
prisma.result.count({
where: {
typebotId: typebot.id,
isArchived: false,
createdAt: date
? {
gte: date,
}
: undefined,
},
}),
prisma.result.count({
where: {
typebotId: typebot.id,
isArchived: false,
hasStarted: true,
createdAt: date
? {
gte: date,
}
: undefined,
},
}),
prisma.result.count({
where: {
typebotId: typebot.id,
isArchived: false,
isCompleted: true,
createdAt: date
? {
gte: date,
}
: undefined,
},
}),
]
)
const stats: Stats = {
totalViews,
totalStarts,
totalCompleted,
}
return {
stats,
}
})

View File

@ -1,8 +1,10 @@
import { router } from '@/helpers/server/trpc' import { router } from '@/helpers/server/trpc'
import { getTotalAnswers } from './getTotalAnswers' import { getTotalAnswers } from './getTotalAnswers'
import { getTotalVisitedEdges } from './getTotalVisitedEdges' import { getTotalVisitedEdges } from './getTotalVisitedEdges'
import { getStats } from './getStats'
export const analyticsRouter = router({ export const analyticsRouter = router({
getTotalAnswers, getTotalAnswers,
getTotalVisitedEdges, getTotalVisitedEdges,
getStats,
}) })

View File

@ -6,7 +6,7 @@ import {
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider' import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { Stats } from '@typebot.io/schemas' import { Stats } from '@typebot.io/schemas'
import React, { useState } from 'react' import React from 'react'
import { StatsCards } from './StatsCards' import { StatsCards } from './StatsCards'
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal' import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
import { Graph } from '@/features/graph/components/Graph' import { Graph } from '@/features/graph/components/Graph'
@ -15,14 +15,22 @@ import { useTranslate } from '@tolgee/react'
import { trpc } from '@/lib/trpc' import { trpc } from '@/lib/trpc'
import { isDefined } from '@typebot.io/lib' import { isDefined } from '@typebot.io/lib'
import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider' import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider'
import { defaultTimeFilter, timeFilterValues } from '../constants' import { timeFilterValues } from '../constants'
export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => { type Props = {
timeFilter: (typeof timeFilterValues)[number]
onTimeFilterChange: (timeFilter: (typeof timeFilterValues)[number]) => void
stats?: Stats
}
export const AnalyticsGraphContainer = ({
timeFilter,
onTimeFilterChange,
stats,
}: Props) => {
const { t } = useTranslate() const { t } = useTranslate()
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const { typebot, publishedTypebot } = useTypebot() const { typebot, publishedTypebot } = useTypebot()
const [timeFilter, setTimeFilter] =
useState<(typeof timeFilterValues)[number]>(defaultTimeFilter)
const { data } = trpc.analytics.getTotalAnswers.useQuery( const { data } = trpc.analytics.getTotalAnswers.useQuery(
{ {
typebotId: typebot?.id as string, typebotId: typebot?.id as string,
@ -85,7 +93,7 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
stats={stats} stats={stats}
pos="absolute" pos="absolute"
timeFilter={timeFilter} timeFilter={timeFilter}
setTimeFilter={setTimeFilter} onTimeFilterChange={onTimeFilterChange}
/> />
</Flex> </Flex>
) )

View File

@ -10,8 +10,8 @@ import {
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { Stats } from '@typebot.io/schemas' import { Stats } from '@typebot.io/schemas'
import React from 'react' import React from 'react'
import { DropdownList } from '@/components/DropdownList' import { timeFilterValues } from '../constants'
import { timeFilterLabels, timeFilterValues } from '../constants' import { TimeFilterDropdown } from './TimeFilterDropdown'
const computeCompletionRate = const computeCompletionRate =
(notAvailableLabel: string) => (notAvailableLabel: string) =>
@ -23,12 +23,12 @@ const computeCompletionRate =
export const StatsCards = ({ export const StatsCards = ({
stats, stats,
timeFilter, timeFilter,
setTimeFilter, onTimeFilterChange,
...props ...props
}: { }: {
stats?: Stats stats?: Stats
timeFilter: (typeof timeFilterValues)[number] timeFilter: (typeof timeFilterValues)[number]
setTimeFilter: (timeFilter: (typeof timeFilterValues)[number]) => void onTimeFilterChange: (timeFilter: (typeof timeFilterValues)[number]) => void
} & GridProps) => { } & GridProps) => {
const { t } = useTranslate() const { t } = useTranslate()
const bg = useColorModeValue('white', 'gray.900') const bg = useColorModeValue('white', 'gray.900')
@ -69,15 +69,9 @@ export const StatsCards = ({
<Skeleton w="50%" h="10px" mt="2" /> <Skeleton w="50%" h="10px" mt="2" />
)} )}
</Stat> </Stat>
<DropdownList <TimeFilterDropdown
items={Object.entries(timeFilterLabels).map(([value, label]) => ({ timeFilter={timeFilter}
label, onTimeFilterChange={onTimeFilterChange}
value,
}))}
currentItem={timeFilter}
onItemSelect={(val) =>
setTimeFilter(val as (typeof timeFilterValues)[number])
}
backgroundColor="white" backgroundColor="white"
boxShadow="md" boxShadow="md"
/> />

View File

@ -0,0 +1,26 @@
import { DropdownList } from '@/components/DropdownList'
import { timeFilterLabels, timeFilterValues } from '../constants'
import { ButtonProps } from '@chakra-ui/react'
type Props = {
timeFilter: (typeof timeFilterValues)[number]
onTimeFilterChange: (timeFilter: (typeof timeFilterValues)[number]) => void
} & ButtonProps
export const TimeFilterDropdown = ({
timeFilter,
onTimeFilterChange,
...props
}: Props) => (
<DropdownList
items={Object.entries(timeFilterLabels).map(([value, label]) => ({
label,
value,
}))}
currentItem={timeFilter}
onItemSelect={(val) =>
onTimeFilterChange(val as (typeof timeFilterValues)[number])
}
{...props}
/>
)

View File

@ -14,6 +14,7 @@ import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/consta
import { parseResultHeader } from '@typebot.io/lib/results/parseResultHeader' import { parseResultHeader } from '@typebot.io/lib/results/parseResultHeader'
import { convertResultsToTableData } from '@typebot.io/lib/results/convertResultsToTableData' import { convertResultsToTableData } from '@typebot.io/lib/results/convertResultsToTableData'
import { parseCellContent } from './helpers/parseCellContent' import { parseCellContent } from './helpers/parseCellContent'
import { timeFilterValues } from '../analytics/constants'
const resultsContext = createContext<{ const resultsContext = createContext<{
resultsList: { results: ResultWithAnswers[] }[] | undefined resultsList: { results: ResultWithAnswers[] }[] | undefined
@ -30,11 +31,13 @@ const resultsContext = createContext<{
}>({}) }>({})
export const ResultsProvider = ({ export const ResultsProvider = ({
timeFilter,
children, children,
typebotId, typebotId,
totalResults, totalResults,
onDeleteResults, onDeleteResults,
}: { }: {
timeFilter: (typeof timeFilterValues)[number]
children: ReactNode children: ReactNode
typebotId: string typebotId: string
totalResults: number totalResults: number
@ -43,6 +46,7 @@ export const ResultsProvider = ({
const { publishedTypebot } = useTypebot() const { publishedTypebot } = useTypebot()
const { showToast } = useToast() const { showToast } = useToast()
const { data, fetchNextPage, hasNextPage, refetch } = useResultsQuery({ const { data, fetchNextPage, hasNextPage, refetch } = useResultsQuery({
timeFilter,
typebotId, typebotId,
onError: (error) => { onError: (error) => {
showToast({ description: error }) showToast({ description: error })

View File

@ -4,6 +4,11 @@ import { TRPCError } from '@trpc/server'
import { resultWithAnswersSchema } from '@typebot.io/schemas' import { resultWithAnswersSchema } from '@typebot.io/schemas'
import { z } from 'zod' import { z } from 'zod'
import { isReadTypebotForbidden } from '@/features/typebot/helpers/isReadTypebotForbidden' import { isReadTypebotForbidden } from '@/features/typebot/helpers/isReadTypebotForbidden'
import {
timeFilterValues,
defaultTimeFilter,
} from '@/features/analytics/constants'
import { parseDateFromTimeFilter } from '@/features/analytics/helpers/parseDateFromTimeFilter'
const maxLimit = 100 const maxLimit = 100
@ -26,6 +31,7 @@ export const getResults = authenticatedProcedure
), ),
limit: z.coerce.number().min(1).max(maxLimit).default(50), limit: z.coerce.number().min(1).max(maxLimit).default(50),
cursor: z.string().optional(), cursor: z.string().optional(),
timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter),
}) })
) )
.output( .output(
@ -70,6 +76,9 @@ export const getResults = authenticatedProcedure
}) })
if (!typebot || (await isReadTypebotForbidden(typebot, user))) if (!typebot || (await isReadTypebotForbidden(typebot, user)))
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' }) throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const date = parseDateFromTimeFilter(input.timeFilter)
const results = await prisma.result.findMany({ const results = await prisma.result.findMany({
take: limit + 1, take: limit + 1,
cursor: cursor ? { id: cursor } : undefined, cursor: cursor ? { id: cursor } : undefined,
@ -77,6 +86,11 @@ export const getResults = authenticatedProcedure
typebotId: typebot.id, typebotId: typebot.id,
hasStarted: true, hasStarted: true,
isArchived: false, isArchived: false,
createdAt: date
? {
gte: date,
}
: undefined,
}, },
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',

View File

@ -14,11 +14,15 @@ import {
} from '@chakra-ui/react' } from '@chakra-ui/react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useMemo } from 'react' import { useMemo, useState } from 'react'
import { useStats } from '../hooks/useStats'
import { ResultsProvider } from '../ResultsProvider' import { ResultsProvider } from '../ResultsProvider'
import { ResultsTableContainer } from './ResultsTableContainer' import { ResultsTableContainer } from './ResultsTableContainer'
import { TypebotNotFoundPage } from '@/features/editor/components/TypebotNotFoundPage' import { TypebotNotFoundPage } from '@/features/editor/components/TypebotNotFoundPage'
import { trpc } from '@/lib/trpc'
import {
defaultTimeFilter,
timeFilterValues,
} from '@/features/analytics/constants'
export const ResultsPage = () => { export const ResultsPage = () => {
const router = useRouter() const router = useRouter()
@ -32,18 +36,25 @@ export const ResultsPage = () => {
router.pathname.endsWith('analytics') ? '#f4f5f8' : 'white', router.pathname.endsWith('analytics') ? '#f4f5f8' : 'white',
router.pathname.endsWith('analytics') ? 'gray.850' : 'gray.900' router.pathname.endsWith('analytics') ? 'gray.850' : 'gray.900'
) )
const [timeFilter, setTimeFilter] =
useState<(typeof timeFilterValues)[number]>(defaultTimeFilter)
const { showToast } = useToast() const { showToast } = useToast()
const { stats, mutate } = useStats({ const { data: { stats } = {}, refetch } = trpc.analytics.getStats.useQuery(
typebotId: publishedTypebot?.typebotId, {
onError: (err) => showToast({ title: err.name, description: err.message }), typebotId: publishedTypebot?.typebotId as string,
}) timeFilter,
},
{
enabled: !!publishedTypebot,
onError: (err) => showToast({ description: err.message }),
}
)
const handleDeletedResults = (total: number) => { const handleDeletedResults = () => {
if (!stats) return if (!stats) return
mutate({ refetch()
stats: { ...stats, totalStarts: stats.totalStarts - total },
})
} }
if (is404) return <TypebotNotFoundPage /> if (is404) return <TypebotNotFoundPage />
@ -100,14 +111,22 @@ export const ResultsPage = () => {
{workspace && {workspace &&
publishedTypebot && publishedTypebot &&
(isAnalytics ? ( (isAnalytics ? (
<AnalyticsGraphContainer stats={stats} /> <AnalyticsGraphContainer
stats={stats}
timeFilter={timeFilter}
onTimeFilterChange={setTimeFilter}
/>
) : ( ) : (
<ResultsProvider <ResultsProvider
timeFilter={timeFilter}
typebotId={publishedTypebot.typebotId} typebotId={publishedTypebot.typebotId}
totalResults={stats?.totalStarts ?? 0} totalResults={stats?.totalStarts ?? 0}
onDeleteResults={handleDeletedResults} onDeleteResults={handleDeletedResults}
> >
<ResultsTableContainer /> <ResultsTableContainer
timeFilter={timeFilter}
onTimeFilterChange={setTimeFilter}
/>
</ResultsProvider> </ResultsProvider>
))} ))}
</Flex> </Flex>

View File

@ -6,8 +6,16 @@ import { useResults } from '../ResultsProvider'
import { ResultModal } from './ResultModal' import { ResultModal } from './ResultModal'
import { ResultsTable } from './table/ResultsTable' import { ResultsTable } from './table/ResultsTable'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { timeFilterValues } from '@/features/analytics/constants'
export const ResultsTableContainer = () => { type Props = {
timeFilter: (typeof timeFilterValues)[number]
onTimeFilterChange: (timeFilter: (typeof timeFilterValues)[number]) => void
}
export const ResultsTableContainer = ({
timeFilter,
onTimeFilterChange,
}: Props) => {
const { query } = useRouter() const { query } = useRouter()
const { const {
flatResults: results, flatResults: results,
@ -61,8 +69,10 @@ export const ResultsTableContainer = () => {
data={tableData} data={tableData}
onScrollToBottom={fetchNextPage} onScrollToBottom={fetchNextPage}
hasMore={hasNextPage} hasMore={hasNextPage}
timeFilter={timeFilter}
onLogOpenIndex={handleLogOpenIndex} onLogOpenIndex={handleLogOpenIndex}
onResultExpandIndex={handleResultExpandIndex} onResultExpandIndex={handleResultExpandIndex}
onTimeFilterChange={onTimeFilterChange}
/> />
)} )}
</Stack> </Stack>

View File

@ -31,12 +31,16 @@ import { IndeterminateCheckbox } from './IndeterminateCheckbox'
import { colors } from '@/lib/theme' import { colors } from '@/lib/theme'
import { HeaderIcon } from '../HeaderIcon' import { HeaderIcon } from '../HeaderIcon'
import { parseColumnsOrder } from '@typebot.io/lib/results/parseColumnsOrder' import { parseColumnsOrder } from '@typebot.io/lib/results/parseColumnsOrder'
import { TimeFilterDropdown } from '@/features/analytics/components/TimeFilterDropdown'
import { timeFilterValues } from '@/features/analytics/constants'
type ResultsTableProps = { type ResultsTableProps = {
resultHeader: ResultHeaderCell[] resultHeader: ResultHeaderCell[]
data: TableData[] data: TableData[]
hasMore?: boolean hasMore?: boolean
preferences?: ResultsTablePreferences preferences?: ResultsTablePreferences
timeFilter: (typeof timeFilterValues)[number]
onTimeFilterChange: (timeFilter: (typeof timeFilterValues)[number]) => void
onScrollToBottom: () => void onScrollToBottom: () => void
onLogOpenIndex: (index: number) => () => void onLogOpenIndex: (index: number) => () => void
onResultExpandIndex: (index: number) => () => void onResultExpandIndex: (index: number) => () => void
@ -47,6 +51,8 @@ export const ResultsTable = ({
data, data,
hasMore, hasMore,
preferences, preferences,
timeFilter,
onTimeFilterChange,
onScrollToBottom, onScrollToBottom,
onLogOpenIndex, onLogOpenIndex,
onResultExpandIndex, onResultExpandIndex,
@ -222,6 +228,11 @@ export const ResultsTable = ({
onClearSelection={() => setRowSelection({})} onClearSelection={() => setRowSelection({})}
/> />
)} )}
<TimeFilterDropdown
timeFilter={timeFilter}
onTimeFilterChange={onTimeFilterChange}
size="sm"
/>
<TableSettingsButton <TableSettingsButton
resultHeader={resultHeader} resultHeader={resultHeader}
columnVisibility={columnsVisibility} columnVisibility={columnsVisibility}

View File

@ -1,15 +1,17 @@
import { timeFilterValues } from '@/features/analytics/constants'
import { trpc } from '@/lib/trpc' import { trpc } from '@/lib/trpc'
export const useResultsQuery = ({ type Params = {
typebotId, timeFilter: (typeof timeFilterValues)[number]
onError,
}: {
typebotId: string typebotId: string
onError?: (error: string) => void onError?: (error: string) => void
}) => { }
export const useResultsQuery = ({ timeFilter, typebotId, onError }: Params) => {
const { data, error, fetchNextPage, hasNextPage, refetch } = const { data, error, fetchNextPage, hasNextPage, refetch } =
trpc.results.getResults.useInfiniteQuery( trpc.results.getResults.useInfiniteQuery(
{ {
timeFilter,
typebotId, typebotId,
}, },
{ {

View File

@ -1,22 +0,0 @@
import { Stats } from '@typebot.io/schemas'
import { fetcher } from '@/helpers/fetcher'
import useSWR from 'swr'
export const useStats = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ stats: Stats }, Error>(
typebotId ? `/api/typebots/${typebotId}/analytics/stats` : null,
fetcher
)
if (error) onError(error)
return {
stats: data?.stats,
isLoading: !error && !data,
mutate,
}
}

View File

@ -5,6 +5,7 @@ import { canReadTypebots } from '@/helpers/databaseRules'
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser' import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
import { methodNotAllowed, notAuthenticated } from '@typebot.io/lib/api' import { methodNotAllowed, notAuthenticated } from '@typebot.io/lib/api'
// TODO: Delete, as it has been migrated to tRPC
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req, res) const user = await getAuthenticatedUser(req, res)
if (!user) return notAuthenticated(res) if (!user) return notAuthenticated(res)

View File

@ -8,7 +8,7 @@ const AnalyticsPage = ResultsPage
export const getServerSideProps = async ( export const getServerSideProps = async (
context: GetServerSidePropsContext context: GetServerSidePropsContext
) => { ) => {
if (!env.NEXT_PUBLIC_POSTHOG_KEY) if (!env.NEXT_PUBLIC_POSTHOG_KEY || env.NEXT_PUBLIC_E2E_TEST)
return { return {
props: {}, props: {},
} }

View File

@ -0,0 +1,4 @@
---
title: 'Get stats'
openapi: GET /v1/typebots/{typebotId}/analytics/stats
---

View File

@ -288,6 +288,10 @@
"api-reference/results/list-logs" "api-reference/results/list-logs"
] ]
}, },
{
"group": "Analytics",
"pages": ["api-reference/analytics/get-stats"]
},
{ {
"group": "Folder", "group": "Folder",
"pages": [ "pages": [

View File

@ -2900,6 +2900,131 @@
} }
} }
}, },
"/v1/typebots/{typebotId}/analytics/stats": {
"get": {
"operationId": "analytics-getStats",
"summary": "Get results stats",
"tags": [
"Analytics"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"in": "path",
"name": "typebotId",
"schema": {
"type": "string"
},
"required": true
},
{
"in": "query",
"name": "timeFilter",
"schema": {
"type": "string",
"enum": [
"today",
"last7Days",
"last30Days",
"yearToDate",
"allTime"
],
"default": "today"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"stats": {
"type": "object",
"properties": {
"totalViews": {
"type": "number"
},
"totalStarts": {
"type": "number"
},
"totalCompleted": {
"type": "number"
}
},
"required": [
"totalViews",
"totalStarts",
"totalCompleted"
]
}
},
"required": [
"stats"
]
}
}
}
},
"400": {
"description": "Invalid input data",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.BAD_REQUEST"
}
}
}
},
"401": {
"description": "Authorization not provided",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.UNAUTHORIZED"
}
}
}
},
"403": {
"description": "Insufficient access",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.FORBIDDEN"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.NOT_FOUND"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
}
}
}
}
}
}
},
"/v1/workspaces": { "/v1/workspaces": {
"get": { "get": {
"operationId": "workspace-listWorkspaces", "operationId": "workspace-listWorkspaces",
@ -10851,6 +10976,21 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"in": "query",
"name": "timeFilter",
"schema": {
"type": "string",
"enum": [
"today",
"last7Days",
"last30Days",
"yearToDate",
"allTime"
],
"default": "today"
}
} }
], ],
"responses": { "responses": {

View File

@ -24,11 +24,13 @@ export const answerInputSchema = answerSchema
storageUsed: z.number().nullish(), storageUsed: z.number().nullish(),
}) satisfies z.ZodType<Prisma.AnswerUncheckedUpdateInput> }) satisfies z.ZodType<Prisma.AnswerUncheckedUpdateInput>
export type Stats = { export const statsSchema = z.object({
totalViews: number totalViews: z.number(),
totalStarts: number totalStarts: z.number(),
totalCompleted: number totalCompleted: z.number(),
} })
export type Stats = z.infer<typeof statsSchema>
export type Answer = z.infer<typeof answerSchema> export type Answer = z.infer<typeof answerSchema>