2
0

🧑‍💻 (folders) Add folder trpc endpoints (#1218)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced folder management capabilities including creation,
deletion, update, listing, and retrieval within workspaces.
- Added telemetry tracking for client events, Typebot publish events,
and analytics page views.
	- Enhanced settings to track client events under specific conditions.
- Implemented server-side logic for analytics tracking with PostHog
integration.
- Added API documentation for folder operations (create, delete, get,
list, update).
- **Refactor**
	- Updated `onConfirm` function's return type in `ConfirmModal`.
	- Simplified folder creation process in tests.
- Refactored logic for handling file upload blocks and parsing publish
events in Typebot publishing.
	- Migrated handler functions to TRPC endpoints for folder operations.
- **Documentation**
- Introduced documentation for new folder and telemetry functionalities.
- **Chores**
- Added new schemas for folders and telemetry events, including event
tracking and folder structure.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Baptiste Arnaud
2024-02-05 12:14:03 +01:00
committed by GitHub
parent 9014c4ab09
commit 84b9aca40b
37 changed files with 1399 additions and 168 deletions

View File

@ -12,7 +12,7 @@ import { useTranslate } from '@tolgee/react'
type ConfirmDeleteModalProps = {
isOpen: boolean
onConfirm: () => Promise<unknown>
onConfirm: () => Promise<unknown> | unknown
onClose: () => void
message: JSX.Element
title?: string

View File

@ -11,10 +11,7 @@ test('folders navigation should work', async ({ page }) => {
await createFolderButton.click()
await page.click('text="New folder"')
await page.fill('input[value="New folder"]', 'My folder #1')
await Promise.all([
page.waitForResponse((resp) => resp.request().method() === 'PATCH'),
page.press('input[value="My folder #1"]', 'Enter'),
])
await page.press('input[value="My folder #1"]', 'Enter')
await page.click('li:has-text("My folder #1")')
await expect(page.locator('h1:has-text("My folder #1")')).toBeVisible()
await createFolderButton.click()

View File

@ -0,0 +1,76 @@
import prisma from '@typebot.io/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { DashboardFolder, Plan, WorkspaceRole } from '@typebot.io/prisma'
import { folderSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { getUserRoleInWorkspace } from '@/features/workspace/helpers/getUserRoleInWorkspace'
import { trackEvents } from '@typebot.io/lib/telemetry/trackEvents'
export const createFolder = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/v1/folders',
protect: true,
summary: 'Create a folder',
tags: ['Folder'],
},
})
.input(
z.object({
workspaceId: z.string(),
folderName: z.string().default('New folder'),
parentFolderId: z.string().optional(),
})
)
.output(
z.object({
folder: folderSchema,
})
)
.mutation(
async ({
input: { folderName, parentFolderId, workspaceId },
ctx: { user },
}) => {
const workspace = await prisma.workspace.findUnique({
where: { id: workspaceId },
select: { id: true, members: true, plan: true },
})
const userRole = getUserRoleInWorkspace(user.id, workspace?.members)
if (
userRole === undefined ||
userRole === WorkspaceRole.GUEST ||
!workspace
)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
if (workspace.plan === Plan.FREE)
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You need to upgrade to a paid plan to create folders',
})
const newFolder = await prisma.dashboardFolder.create({
data: {
workspaceId,
name: folderName,
parentFolderId,
} satisfies Partial<DashboardFolder>,
})
await trackEvents([
{
name: 'Folder created',
userId: user.id,
workspaceId,
},
])
return { folder: folderSchema.parse(newFolder) }
}
)

View File

@ -0,0 +1,53 @@
import prisma from '@typebot.io/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { WorkspaceRole } from '@typebot.io/prisma'
import { folderSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { getUserRoleInWorkspace } from '@/features/workspace/helpers/getUserRoleInWorkspace'
export const deleteFolder = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/v1/folders/{folderId}',
protect: true,
summary: 'Delete a folder',
tags: ['Folder'],
},
})
.input(
z.object({
folderId: z.string(),
workspaceId: z.string(),
})
)
.output(
z.object({
folder: folderSchema,
})
)
.mutation(async ({ input: { folderId, workspaceId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findUnique({
where: { id: workspaceId },
select: { id: true, members: true, plan: true },
})
const userRole = getUserRoleInWorkspace(user.id, workspace?.members)
if (
userRole === undefined ||
userRole === WorkspaceRole.GUEST ||
!workspace
)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const folder = await prisma.dashboardFolder.delete({
where: {
id: folderId,
},
})
return { folder }
})

View File

@ -0,0 +1,59 @@
import prisma from '@typebot.io/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { WorkspaceRole } from '@typebot.io/prisma'
import { folderSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { getUserRoleInWorkspace } from '@/features/workspace/helpers/getUserRoleInWorkspace'
export const getFolder = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/v1/folders/{folderId}',
protect: true,
summary: 'Get folder',
tags: ['Folder'],
},
})
.input(
z.object({
workspaceId: z.string(),
folderId: z.string(),
})
)
.output(
z.object({
folder: folderSchema,
})
)
.query(async ({ input: { workspaceId, folderId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findUnique({
where: { id: workspaceId },
select: { id: true, members: true, plan: true },
})
const userRole = getUserRoleInWorkspace(user.id, workspace?.members)
if (
userRole === undefined ||
userRole === WorkspaceRole.GUEST ||
!workspace
)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const folder = await prisma.dashboardFolder.findUnique({
where: {
id: folderId,
},
})
if (!folder)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Folder not found',
})
return { folder }
})

View File

@ -0,0 +1,57 @@
import prisma from '@typebot.io/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { WorkspaceRole } from '@typebot.io/prisma'
import { folderSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { getUserRoleInWorkspace } from '@/features/workspace/helpers/getUserRoleInWorkspace'
export const listFolders = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/v1/folders',
protect: true,
summary: 'List folders',
tags: ['Folder'],
},
})
.input(
z.object({
workspaceId: z.string(),
parentFolderId: z.string().optional(),
})
)
.output(
z.object({
folders: z.array(folderSchema),
})
)
.query(async ({ input: { workspaceId, parentFolderId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findUnique({
where: { id: workspaceId },
select: { id: true, members: true, plan: true },
})
const userRole = getUserRoleInWorkspace(user.id, workspace?.members)
if (
userRole === undefined ||
userRole === WorkspaceRole.GUEST ||
!workspace
)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
const folders = await prisma.dashboardFolder.findMany({
where: {
workspaceId,
parentFolderId,
},
orderBy: {
createdAt: 'desc',
},
})
return { folders }
})

View File

@ -0,0 +1,14 @@
import { router } from '@/helpers/server/trpc'
import { createFolder } from './createFolder'
import { updateFolder } from './updateFolder'
import { deleteFolder } from './deleteFolder'
import { listFolders } from './listFolders'
import { getFolder } from './getFolder'
export const folderRouter = router({
getFolder,
createFolder,
updateFolder,
deleteFolder,
listFolders,
})

View File

@ -0,0 +1,71 @@
import prisma from '@typebot.io/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { Plan, WorkspaceRole } from '@typebot.io/prisma'
import { folderSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { getUserRoleInWorkspace } from '@/features/workspace/helpers/getUserRoleInWorkspace'
export const updateFolder = authenticatedProcedure
.meta({
openapi: {
method: 'PATCH',
path: '/v1/folders/{folderId}',
protect: true,
summary: 'Update a folder',
tags: ['Folder'],
},
})
.input(
z.object({
folderId: z.string(),
workspaceId: z.string(),
folder: folderSchema
.pick({
name: true,
parentFolderId: true,
})
.partial(),
})
)
.output(
z.object({
folder: folderSchema,
})
)
.mutation(
async ({ input: { folder, folderId, workspaceId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findUnique({
where: { id: workspaceId },
select: { id: true, members: true, plan: true },
})
const userRole = getUserRoleInWorkspace(user.id, workspace?.members)
if (
userRole === undefined ||
userRole === WorkspaceRole.GUEST ||
!workspace
)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
if (workspace.plan === Plan.FREE)
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You need to upgrade to a paid plan to update folders',
})
const updatedFolder = await prisma.dashboardFolder.update({
where: {
id: folderId,
},
data: {
name: folder.name,
parentFolderId: folder.parentFolderId,
},
})
return { folder: folderSchema.parse(updatedFolder) }
}
)

View File

@ -22,10 +22,9 @@ import { ConfirmModal } from '@/components/ConfirmModal'
import { useTypebotDnd } from '../TypebotDndProvider'
import { useRouter } from 'next/router'
import React, { useMemo } from 'react'
import { deleteFolderQuery } from '../queries/deleteFolderQuery'
import { useToast } from '@/hooks/useToast'
import { updateFolderQuery } from '../queries/updateFolderQuery'
import { T, useTranslate } from '@tolgee/react'
import { trpc } from '@/lib/trpc'
export const FolderButton = ({
folder,
@ -34,7 +33,7 @@ export const FolderButton = ({
}: {
folder: DashboardFolder
onFolderDeleted: () => void
onFolderRenamed: (newName: string) => void
onFolderRenamed: () => void
}) => {
const { t } = useTranslate()
const router = useRouter()
@ -47,21 +46,29 @@ export const FolderButton = ({
const { isOpen, onOpen, onClose } = useDisclosure()
const { showToast } = useToast()
const onDeleteClick = async () => {
const { error } = await deleteFolderQuery(folder.id)
return error
? showToast({
description: error.message,
})
: onFolderDeleted()
}
const { mutate: deleteFolder } = trpc.folders.deleteFolder.useMutation({
onError: (error) => {
showToast({ description: error.message })
},
onSuccess: onFolderDeleted,
})
const { mutate: updateFolder } = trpc.folders.updateFolder.useMutation({
onError: (error) => {
showToast({ description: error.message })
},
onSuccess: onFolderRenamed,
})
const onRenameSubmit = async (newName: string) => {
if (newName === '' || newName === folder.name) return
const { error } = await updateFolderQuery(folder.id, { name: newName })
return error
? showToast({ title: t('errorMessage'), description: error.message })
: onFolderRenamed(newName)
updateFolder({
workspaceId: folder.workspaceId,
folderId: folder.id,
folder: {
name: newName,
},
})
}
const handleClick = () => {
@ -148,7 +155,12 @@ export const FolderButton = ({
</Text>
}
title={`Delete ${folder.name}?`}
onConfirm={onDeleteClick}
onConfirm={() =>
deleteFolder({
workspaceId: folder.workspaceId,
folderId: folder.id,
})
}
confirmButtonColor="red"
/>
</Button>

View File

@ -14,14 +14,11 @@ import React, { useState } from 'react'
import { BackButton } from './BackButton'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { useToast } from '@/hooks/useToast'
import { useFolders } from '../hooks/useFolders'
import { createFolderQuery } from '../queries/createFolderQuery'
import { CreateBotButton } from './CreateBotButton'
import { CreateFolderButton } from './CreateFolderButton'
import { ButtonSkeleton, FolderButton } from './FolderButton'
import { TypebotButton } from './TypebotButton'
import { TypebotCardOverlay } from './TypebotButtonOverlay'
import { useTranslate } from '@tolgee/react'
import { useTypebots } from '@/features/dashboard/hooks/useTypebots'
import { TypebotInDashboard } from '@/features/dashboard/types'
import { trpc } from '@/lib/trpc'
@ -31,7 +28,6 @@ type Props = { folder: DashboardFolder | null }
const dragDistanceTolerance = 20
export const FolderContent = ({ folder }: Props) => {
const { t } = useTranslate()
const { workspace, currentRole } = useWorkspace()
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
const {
@ -52,16 +48,30 @@ export const FolderContent = ({ folder }: Props) => {
const { showToast } = useToast()
const {
folders,
data: { folders } = {},
isLoading: isFolderLoading,
mutate: mutateFolders,
} = useFolders({
workspaceId: workspace?.id,
parentId: folder?.id,
refetch: refetchFolders,
} = trpc.folders.listFolders.useQuery(
{
workspaceId: workspace?.id as string,
parentFolderId: folder?.id,
},
{
enabled: !!workspace,
onError: (error) => {
showToast({
description: error.message,
})
},
}
)
const { mutate: createFolder } = trpc.folders.createFolder.useMutation({
onError: (error) => {
showToast({
description: error.message,
})
showToast({ description: error.message })
},
onSuccess: () => {
refetchFolders()
},
})
@ -98,36 +108,14 @@ export const FolderContent = ({ folder }: Props) => {
})
}
const handleCreateFolder = async () => {
const handleCreateFolder = () => {
if (!folders || !workspace) return
setIsCreatingFolder(true)
const { error, data: newFolder } = await createFolderQuery(workspace.id, {
parentFolderId: folder?.id ?? null,
createFolder({
workspaceId: workspace.id,
parentFolderId: folder?.id,
})
setIsCreatingFolder(false)
if (error)
return showToast({
title: t('errorMessage'),
description: error.message,
})
if (newFolder) mutateFolders({ folders: [...folders, newFolder] })
}
const handleTypebotUpdated = () => {
if (!typebots) return
refetchTypebots()
}
const handleFolderDeleted = (deletedId: string) => {
if (!folders) return
mutateFolders({ folders: folders.filter((f) => f.id !== deletedId) })
}
const handleFolderRenamed = (folderId: string, name: string) => {
if (!folders) return
mutateFolders({
folders: folders.map((f) => (f.id === folderId ? { ...f, name } : f)),
})
}
const handleMouseUp = async () => {
@ -169,7 +157,7 @@ export const FolderContent = ({ folder }: Props) => {
return (
<Flex w="full" flex="1" justify="center">
<Stack w="1000px" spacing={6}>
<Stack w="1000px" spacing={6} pt="4">
<Skeleton isLoaded={folder?.name !== undefined}>
<Heading as="h1">{folder?.name}</Heading>
</Skeleton>
@ -197,10 +185,8 @@ export const FolderContent = ({ folder }: Props) => {
<FolderButton
key={folder.id.toString()}
folder={folder}
onFolderDeleted={() => handleFolderDeleted(folder.id)}
onFolderRenamed={(newName: string) =>
handleFolderRenamed(folder.id, newName)
}
onFolderDeleted={refetchFolders}
onFolderRenamed={() => refetchFolders()}
/>
))}
{isTypebotLoading && <ButtonSkeleton />}
@ -209,7 +195,7 @@ export const FolderContent = ({ folder }: Props) => {
<TypebotButton
key={typebot.id.toString()}
typebot={typebot}
onTypebotUpdated={handleTypebotUpdated}
onTypebotUpdated={refetchTypebots}
onMouseDown={handleMouseDown(typebot)}
/>
))}

View File

@ -4,24 +4,32 @@ import { useToast } from '@/hooks/useToast'
import { useTranslate } from '@tolgee/react'
import { Stack, Flex, Spinner } from '@chakra-ui/react'
import { useRouter } from 'next/router'
import { useFolder } from '../hooks/useFolder'
import { TypebotDndProvider } from '../TypebotDndProvider'
import { FolderContent } from './FolderContent'
import { trpc } from '@/lib/trpc'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
export const FolderPage = () => {
const { t } = useTranslate()
const router = useRouter()
const { workspace } = useWorkspace()
const { showToast } = useToast()
const { folder } = useFolder({
folderId: router.query.id?.toString(),
onError: (error) => {
showToast({
description: error.message,
})
const { data: { folder } = {} } = trpc.folders.getFolder.useQuery(
{
folderId: router.query.id as string,
workspaceId: workspace?.id as string,
},
})
{
enabled: !!workspace && !!router.query.id,
onError: (error) => {
showToast({
description: error.message,
})
},
}
)
return (
<Stack minH="100vh">

View File

@ -1,22 +0,0 @@
import { fetcher } from '@/helpers/fetcher'
import { DashboardFolder } from '@typebot.io/prisma'
import useSWR from 'swr'
export const useFolder = ({
folderId,
onError,
}: {
folderId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<{ folder: DashboardFolder }, Error>(
`/api/folders/${folderId}`,
fetcher
)
if (error) onError(error)
return {
folder: data?.folder,
isLoading: !error && !data,
mutate,
}
}

View File

@ -1,30 +0,0 @@
import { fetcher } from '@/helpers/fetcher'
import { DashboardFolder } from '@typebot.io/prisma'
import { stringify } from 'qs'
import useSWR from 'swr'
import { env } from '@typebot.io/env'
export const useFolders = ({
parentId,
workspaceId,
onError,
}: {
workspaceId?: string
parentId?: string
onError: (error: Error) => void
}) => {
const params = stringify({ parentId, workspaceId })
const { data, error, mutate } = useSWR<{ folders: DashboardFolder[] }, Error>(
workspaceId ? `/api/folders?${params}` : null,
fetcher,
{
dedupingInterval: env.NEXT_PUBLIC_E2E_TEST ? 0 : undefined,
}
)
if (error) onError(error)
return {
folders: data?.folders,
isLoading: !error && !data,
mutate,
}
}

View File

@ -1,12 +0,0 @@
import { DashboardFolder } from '@typebot.io/prisma'
import { sendRequest } from '@typebot.io/lib'
export const createFolderQuery = async (
workspaceId: string,
folder: Pick<DashboardFolder, 'parentFolderId'>
) =>
sendRequest<DashboardFolder>({
url: `/api/folders`,
method: 'POST',
body: { ...folder, workspaceId },
})

View File

@ -1,7 +0,0 @@
import { sendRequest } from '@typebot.io/lib'
export const deleteFolderQuery = async (id: string) =>
sendRequest({
url: `/api/folders/${id}`,
method: 'DELETE',
})

View File

@ -1,12 +0,0 @@
import { DashboardFolder } from '@typebot.io/prisma'
import { sendRequest } from '@typebot.io/lib'
export const updateFolderQuery = async (
id: string,
folder: Partial<DashboardFolder>
) =>
sendRequest({
url: `/api/folders/${id}`,
method: 'PATCH',
body: folder,
})

View File

@ -0,0 +1,6 @@
import { router } from '@/helpers/server/trpc'
import { trackClientEvents } from './trackClientEvents'
export const telemetryRouter = router({
trackClientEvents,
})

View File

@ -0,0 +1,95 @@
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import prisma from '@typebot.io/lib/prisma'
import { getUserRoleInWorkspace } from '@/features/workspace/helpers/getUserRoleInWorkspace'
import { WorkspaceRole } from '@typebot.io/prisma'
import { isWriteTypebotForbidden } from '@/features/typebot/helpers/isWriteTypebotForbidden'
import { trackEvents } from '@typebot.io/lib/telemetry/trackEvents'
import { clientSideCreateEventSchema } from '@typebot.io/schemas'
export const trackClientEvents = authenticatedProcedure
.input(
z.object({
events: z.array(clientSideCreateEventSchema),
})
)
.output(
z.object({
message: z.literal('success'),
})
)
.mutation(async ({ input: { events }, ctx: { user } }) => {
const workspaces = await prisma.workspace.findMany({
where: {
id: {
in: events
.filter((event) => 'workspaceId' in event)
.map((event) => (event as { workspaceId: string }).workspaceId),
},
},
select: {
id: true,
members: true,
},
})
const typebots = await prisma.typebot.findMany({
where: {
id: {
in: events
.filter((event) => 'typebotId' in event)
.map((event) => (event as { typebotId: string }).typebotId),
},
},
select: {
id: true,
workspaceId: true,
workspace: {
select: {
isSuspended: true,
isPastDue: true,
members: {
select: {
role: true,
userId: true,
},
},
},
},
collaborators: {
select: {
userId: true,
type: true,
},
},
},
})
for (const event of events) {
if ('workspaceId' in event) {
const workspace = workspaces.find((w) => w.id === event.workspaceId)
const userRole = getUserRoleInWorkspace(user.id, workspace?.members)
if (
userRole === undefined ||
userRole === WorkspaceRole.GUEST ||
!workspace
)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Workspace not found',
})
}
if ('typebotId' in event) {
const typebot = typebots.find((t) => t.id === event.typebotId)
if (!typebot || (await isWriteTypebotForbidden(typebot, user)))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
}
}
await trackEvents(events.map((e) => ({ ...e, userId: user.id })))
return { message: 'success' }
})

View File

@ -0,0 +1,48 @@
import { env } from '@typebot.io/env'
import prisma from '@typebot.io/lib/prisma'
import { parseGroups, Typebot } from '@typebot.io/schemas'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
type Props = {
existingTypebot: Pick<Typebot, 'id' | 'workspaceId'>
userId: string
hasFileUploadBlocks: boolean
}
export const parseTypebotPublishEvents = async ({
existingTypebot,
userId,
hasFileUploadBlocks,
}: Props) => {
if (!env.NEXT_PUBLIC_POSTHOG_KEY) return []
const events = []
const existingPublishedTypebot = await prisma.publicTypebot.findFirst({
where: {
typebotId: existingTypebot.id,
},
select: {
version: true,
groups: true,
settings: true,
},
})
const isPublishingFileUploadBlockForTheFirstTime =
hasFileUploadBlocks &&
(!existingPublishedTypebot ||
!parseGroups(existingPublishedTypebot.groups, {
typebotVersion: existingPublishedTypebot.version,
}).some((group) =>
group.blocks.some((block) => block.type === InputBlockType.FILE)
))
if (isPublishingFileUploadBlockForTheFirstTime)
events.push({
name: 'File upload block published',
workspaceId: existingTypebot.workspaceId,
typebotId: existingTypebot.id,
userId,
} as const)
return events
}

View File

@ -0,0 +1,31 @@
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
import prisma from '@typebot.io/lib/prisma'
import { trackEvents } from '@typebot.io/lib/telemetry/trackEvents'
import { User } from '@typebot.io/schemas'
import { GetServerSidePropsContext } from 'next'
import { getServerSession } from 'next-auth'
export const trackAnalyticsPageView = async (
context: GetServerSidePropsContext
) => {
const typebotId = context.params?.typebotId as string | undefined
if (!typebotId) return
const typebot = await prisma.typebot.findUnique({
where: { id: typebotId },
select: { workspaceId: true },
})
if (!typebot) return
const session = await getServerSession(
context.req,
context.res,
getAuthOptions({})
)
await trackEvents([
{
name: 'Analytics visited',
typebotId,
userId: (session?.user as User).id,
workspaceId: typebot.workspaceId,
},
])
}

View File

@ -10,6 +10,9 @@ import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
import { useTranslate } from '@tolgee/react'
import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constants'
import { trpc } from '@/lib/trpc'
import { env } from '@typebot.io/env'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
type Props = {
isBrandingEnabled: boolean
@ -27,8 +30,12 @@ export const GeneralSettings = ({
const { t } = useTranslate()
const { isOpen, onOpen, onClose } = useDisclosure()
const { workspace } = useWorkspace()
const { typebot } = useTypebot()
const isWorkspaceFreePlan = isFreePlan(workspace)
const { mutate: trackClientEvents } =
trpc.telemetry.trackClientEvents.useMutation()
const handleSelectFont = (font: string) =>
onGeneralThemeChange({ ...generalTheme, font })
@ -37,6 +44,22 @@ export const GeneralSettings = ({
const updateBranding = () => {
if (isBrandingEnabled && isWorkspaceFreePlan) return
if (
env.NEXT_PUBLIC_POSTHOG_KEY &&
typebot &&
workspace &&
isBrandingEnabled
) {
trackClientEvents({
events: [
{
name: 'Branding removed',
typebotId: typebot.id,
workspaceId: workspace.id,
},
],
})
}
onBrandingChange(!isBrandingEnabled)
}

View File

@ -16,6 +16,7 @@ import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/const
import { computeRiskLevel } from '@typebot.io/radar'
import { env } from '@typebot.io/env'
import { trackEvents } from '@typebot.io/lib/telemetry/trackEvents'
import { parseTypebotPublishEvents } from '@/features/telemetry/helpers/parseTypebotPublishEvents'
export const publishTypebot = authenticatedProcedure
.meta({
@ -71,19 +72,17 @@ export const publishTypebot = authenticatedProcedure
)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
if (existingTypebot.workspace.plan === Plan.FREE) {
const hasFileUploadBlocks = parseGroups(existingTypebot.groups, {
typebotVersion: existingTypebot.version,
}).some((group) =>
group.blocks.some((block) => block.type === InputBlockType.FILE)
)
const hasFileUploadBlocks = parseGroups(existingTypebot.groups, {
typebotVersion: existingTypebot.version,
}).some((group) =>
group.blocks.some((block) => block.type === InputBlockType.FILE)
)
if (hasFileUploadBlocks)
throw new TRPCError({
code: 'BAD_REQUEST',
message: "File upload blocks can't be published on the free plan",
})
}
if (hasFileUploadBlocks && existingTypebot.workspace.plan === Plan.FREE)
throw new TRPCError({
code: 'BAD_REQUEST',
message: "File upload blocks can't be published on the free plan",
})
const typebotWasVerified =
existingTypebot.riskLevel === -1 || existingTypebot.workspace.isVerified
@ -133,6 +132,12 @@ export const publishTypebot = authenticatedProcedure
}
}
const publishEvents = await parseTypebotPublishEvents({
existingTypebot,
userId: user.id,
hasFileUploadBlocks,
})
if (existingTypebot.publishedTypebot)
await prisma.publicTypebot.updateMany({
where: {
@ -175,6 +180,7 @@ export const publishTypebot = authenticatedProcedure
})
await trackEvents([
...publishEvents,
{
name: 'Typebot published',
workspaceId: existingTypebot.workspaceId,

View File

@ -6,6 +6,7 @@ import { internalWhatsAppRouter } from '@/features/whatsapp/router'
import { zemanticAiRouter } from '@/features/blocks/integrations/zemanticAi/api/router'
import { forgeRouter } from '@/features/forge/api/router'
import { googleSheetsRouter } from '@/features/blocks/integrations/googleSheets/api/router'
import { telemetryRouter } from '@/features/telemetry/api/router'
export const internalRouter = router({
getAppVersionProcedure,
@ -15,6 +16,7 @@ export const internalRouter = router({
zemanticAI: zemanticAiRouter,
forge: forgeRouter,
sheets: googleSheetsRouter,
telemetry: telemetryRouter,
})
export type InternalRouter = typeof internalRouter

View File

@ -11,6 +11,7 @@ import { analyticsRouter } from '@/features/analytics/api/router'
import { collaboratorsRouter } from '@/features/collaboration/api/router'
import { customDomainsRouter } from '@/features/customDomains/api/router'
import { publicWhatsAppRouter } from '@/features/whatsapp/router'
import { folderRouter } from '@/features/folders/api/router'
export const publicRouter = router({
getLinkedTypebots,
@ -25,6 +26,7 @@ export const publicRouter = router({
collaborators: collaboratorsRouter,
customDomains: customDomainsRouter,
whatsApp: publicWhatsAppRouter,
folders: folderRouter,
})
export type PublicRouter = typeof publicRouter

View File

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

View File

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

View File

@ -1,5 +1,21 @@
import { GetServerSidePropsContext } from 'next'
import ResultsPage from '../results'
import { env } from '@typebot.io/env'
import { trackAnalyticsPageView } from '@/features/telemetry/helpers/trackAnalyticsPageView'
const AnalyticsPage = ResultsPage
export const getServerSideProps = async (
context: GetServerSidePropsContext
) => {
if (!env.NEXT_PUBLIC_POSTHOG_KEY)
return {
props: {},
}
await trackAnalyticsPageView(context)
return {
props: {},
}
}
export default AnalyticsPage

View File

@ -0,0 +1,4 @@
---
title: 'Create a folder'
openapi: POST /v1/folders
---

View File

@ -0,0 +1,4 @@
---
title: 'Delete a folder'
openapi: DELETE /v1/folders/{folderId}
---

View File

@ -0,0 +1,4 @@
---
title: 'Get a folder'
openapi: GET /v1/folders/{folderId}
---

View File

@ -0,0 +1,4 @@
---
title: 'List folders'
openapi: GET /v1/folders
---

View File

@ -0,0 +1,4 @@
---
title: 'Update a folder'
openapi: PATCH /v1/folders/{folderId}
---

View File

@ -288,6 +288,16 @@
"api-reference/results/list-logs"
]
},
{
"group": "Folder",
"pages": [
"api-reference/folder/list",
"api-reference/folder/get",
"api-reference/folder/create",
"api-reference/folder/update",
"api-reference/folder/delete"
]
},
{
"group": "Workspace",
"pages": [

View File

@ -14697,6 +14697,677 @@
}
}
}
},
"/v1/folders/{folderId}": {
"get": {
"operationId": "folders-getFolder",
"summary": "Get folder",
"tags": [
"Folder"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"in": "path",
"name": "folderId",
"schema": {
"type": "string"
},
"required": true
},
{
"in": "query",
"name": "workspaceId",
"schema": {
"type": "string"
},
"required": true
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"folder": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"name": {
"type": "string"
},
"parentFolderId": {
"type": "string",
"nullable": true
},
"workspaceId": {
"type": "string"
}
},
"required": [
"id",
"createdAt",
"updatedAt",
"name",
"parentFolderId",
"workspaceId"
]
}
},
"required": [
"folder"
]
}
}
}
},
"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"
}
}
}
}
}
},
"patch": {
"operationId": "folders-updateFolder",
"summary": "Update a folder",
"tags": [
"Folder"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"in": "path",
"name": "folderId",
"schema": {
"type": "string"
},
"required": true
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"workspaceId": {
"type": "string"
},
"folder": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"parentFolderId": {
"type": "string",
"nullable": true
}
}
}
},
"required": [
"workspaceId",
"folder"
]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"folder": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"name": {
"type": "string"
},
"parentFolderId": {
"type": "string",
"nullable": true
},
"workspaceId": {
"type": "string"
}
},
"required": [
"id",
"createdAt",
"updatedAt",
"name",
"parentFolderId",
"workspaceId"
]
}
},
"required": [
"folder"
]
}
}
}
},
"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"
}
}
}
}
}
},
"delete": {
"operationId": "folders-deleteFolder",
"summary": "Delete a folder",
"tags": [
"Folder"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"in": "path",
"name": "folderId",
"schema": {
"type": "string"
},
"required": true
},
{
"in": "query",
"name": "workspaceId",
"schema": {
"type": "string"
},
"required": true
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"folder": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"name": {
"type": "string"
},
"parentFolderId": {
"type": "string",
"nullable": true
},
"workspaceId": {
"type": "string"
}
},
"required": [
"id",
"createdAt",
"updatedAt",
"name",
"parentFolderId",
"workspaceId"
]
}
},
"required": [
"folder"
]
}
}
}
},
"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/folders": {
"post": {
"operationId": "folders-createFolder",
"summary": "Create a folder",
"tags": [
"Folder"
],
"security": [
{
"Authorization": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"workspaceId": {
"type": "string"
},
"folderName": {
"type": "string",
"default": "New folder"
},
"parentFolderId": {
"type": "string"
}
},
"required": [
"workspaceId"
]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"folder": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"name": {
"type": "string"
},
"parentFolderId": {
"type": "string",
"nullable": true
},
"workspaceId": {
"type": "string"
}
},
"required": [
"id",
"createdAt",
"updatedAt",
"name",
"parentFolderId",
"workspaceId"
]
}
},
"required": [
"folder"
]
}
}
}
},
"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"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
}
}
}
}
}
},
"get": {
"operationId": "folders-listFolders",
"summary": "List folders",
"tags": [
"Folder"
],
"security": [
{
"Authorization": []
}
],
"parameters": [
{
"in": "query",
"name": "workspaceId",
"schema": {
"type": "string"
},
"required": true
},
{
"in": "query",
"name": "parentFolderId",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"folders": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"name": {
"type": "string"
},
"parentFolderId": {
"type": "string",
"nullable": true
},
"workspaceId": {
"type": "string"
}
},
"required": [
"id",
"createdAt",
"updatedAt",
"name",
"parentFolderId",
"workspaceId"
]
}
}
},
"required": [
"folders"
]
}
}
}
},
"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"
}
}
}
}
}
}
}
},
"components": {

View File

@ -0,0 +1,13 @@
import { DashboardFolder } from '@typebot.io/prisma'
import { z } from 'zod'
export const folderSchema = z.object({
id: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
name: z.string(),
parentFolderId: z.string().nullable(),
workspaceId: z.string(),
}) satisfies z.ZodType<DashboardFolder>
export type Folder = z.infer<typeof folderSchema>

View File

@ -144,6 +144,32 @@ export const workspaceNotPastDueEventSchema = workspaceEvent.merge(
})
)
export const removedBrandingEventSchema = typebotEvent.merge(
z.object({
name: z.literal('Branding removed'),
})
)
export const createdFolderEventSchema = workspaceEvent.merge(
z.object({
name: z.literal('Folder created'),
})
)
export const publishedFileUploadBlockEventSchema = typebotEvent.merge(
z.object({
name: z.literal('File upload block published'),
})
)
export const visitedAnalyticsEventSchema = typebotEvent.merge(
z.object({
name: z.literal('Analytics visited'),
})
)
export const clientSideEvents = [removedBrandingEventSchema] as const
export const eventSchema = z.discriminatedUnion('name', [
workspaceCreatedEventSchema,
userCreatedEventSchema,
@ -159,6 +185,14 @@ export const eventSchema = z.discriminatedUnion('name', [
userUpdatedEventSchema,
customDomainAddedEventSchema,
whatsAppCredentialsCreatedEventSchema,
createdFolderEventSchema,
publishedFileUploadBlockEventSchema,
visitedAnalyticsEventSchema,
...clientSideEvents,
])
export const clientSideCreateEventSchema = removedBrandingEventSchema.omit({
userId: true,
})
export type TelemetryEvent = z.infer<typeof eventSchema>

View File

@ -14,3 +14,5 @@ export * from './features/items'
export * from './features/analytics'
export * from './features/events'
export * from './features/user/schema'
export * from './features/folder'
export * from './features/telemetry'