From 84b9aca40b59c39c5c98bc2d189658b7d1686fad Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Mon, 5 Feb 2024 12:14:03 +0100 Subject: [PATCH] :technologist: (folders) Add folder trpc endpoints (#1218) ## 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. --- apps/builder/src/components/ConfirmModal.tsx | 2 +- .../src/features/dashboard/dashboard.spec.ts | 5 +- .../src/features/folders/api/createFolder.ts | 76 ++ .../src/features/folders/api/deleteFolder.ts | 53 ++ .../src/features/folders/api/getFolder.ts | 59 ++ .../src/features/folders/api/listFolders.ts | 57 ++ .../src/features/folders/api/router.ts | 14 + .../src/features/folders/api/updateFolder.ts | 71 ++ .../folders/components/FolderButton.tsx | 44 +- .../folders/components/FolderContent.tsx | 74 +- .../folders/components/FolderPage.tsx | 24 +- .../src/features/folders/hooks/useFolder.ts | 22 - .../src/features/folders/hooks/useFolders.ts | 30 - .../folders/queries/createFolderQuery.ts | 12 - .../folders/queries/deleteFolderQuery.ts | 7 - .../folders/queries/updateFolderQuery.ts | 12 - .../src/features/telemetry/api/router.ts | 6 + .../telemetry/api/trackClientEvents.ts | 95 +++ .../helpers/parseTypebotPublishEvents.ts | 48 ++ .../helpers/trackAnalyticsPageView.ts | 31 + .../components/general/GeneralSettings.tsx | 23 + .../features/typebot/api/publishTypebot.ts | 30 +- .../helpers/server/routers/internalRouter.ts | 2 + .../helpers/server/routers/publicRouter.ts | 2 + apps/builder/src/pages/api/folders.ts | 1 + apps/builder/src/pages/api/folders/[id].ts | 1 + .../[typebotId]/results/analytics.tsx | 16 + apps/docs/api-reference/folder/create.mdx | 4 + apps/docs/api-reference/folder/delete.mdx | 4 + apps/docs/api-reference/folder/get.mdx | 4 + apps/docs/api-reference/folder/list.mdx | 4 + apps/docs/api-reference/folder/update.mdx | 4 + apps/docs/mint.json | 10 + apps/docs/openapi/builder.json | 671 ++++++++++++++++++ packages/schemas/features/folder.ts | 13 + packages/schemas/features/telemetry.ts | 34 + packages/schemas/index.ts | 2 + 37 files changed, 1399 insertions(+), 168 deletions(-) create mode 100644 apps/builder/src/features/folders/api/createFolder.ts create mode 100644 apps/builder/src/features/folders/api/deleteFolder.ts create mode 100644 apps/builder/src/features/folders/api/getFolder.ts create mode 100644 apps/builder/src/features/folders/api/listFolders.ts create mode 100644 apps/builder/src/features/folders/api/router.ts create mode 100644 apps/builder/src/features/folders/api/updateFolder.ts delete mode 100644 apps/builder/src/features/folders/hooks/useFolder.ts delete mode 100644 apps/builder/src/features/folders/hooks/useFolders.ts delete mode 100644 apps/builder/src/features/folders/queries/createFolderQuery.ts delete mode 100644 apps/builder/src/features/folders/queries/deleteFolderQuery.ts delete mode 100644 apps/builder/src/features/folders/queries/updateFolderQuery.ts create mode 100644 apps/builder/src/features/telemetry/api/router.ts create mode 100644 apps/builder/src/features/telemetry/api/trackClientEvents.ts create mode 100644 apps/builder/src/features/telemetry/helpers/parseTypebotPublishEvents.ts create mode 100644 apps/builder/src/features/telemetry/helpers/trackAnalyticsPageView.ts create mode 100644 apps/docs/api-reference/folder/create.mdx create mode 100644 apps/docs/api-reference/folder/delete.mdx create mode 100644 apps/docs/api-reference/folder/get.mdx create mode 100644 apps/docs/api-reference/folder/list.mdx create mode 100644 apps/docs/api-reference/folder/update.mdx create mode 100644 packages/schemas/features/folder.ts diff --git a/apps/builder/src/components/ConfirmModal.tsx b/apps/builder/src/components/ConfirmModal.tsx index 0973487f1..0ac9b5469 100644 --- a/apps/builder/src/components/ConfirmModal.tsx +++ b/apps/builder/src/components/ConfirmModal.tsx @@ -12,7 +12,7 @@ import { useTranslate } from '@tolgee/react' type ConfirmDeleteModalProps = { isOpen: boolean - onConfirm: () => Promise + onConfirm: () => Promise | unknown onClose: () => void message: JSX.Element title?: string diff --git a/apps/builder/src/features/dashboard/dashboard.spec.ts b/apps/builder/src/features/dashboard/dashboard.spec.ts index 85b0dd557..26e0566dc 100644 --- a/apps/builder/src/features/dashboard/dashboard.spec.ts +++ b/apps/builder/src/features/dashboard/dashboard.spec.ts @@ -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() diff --git a/apps/builder/src/features/folders/api/createFolder.ts b/apps/builder/src/features/folders/api/createFolder.ts new file mode 100644 index 000000000..1f6d1321e --- /dev/null +++ b/apps/builder/src/features/folders/api/createFolder.ts @@ -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, + }) + + await trackEvents([ + { + name: 'Folder created', + userId: user.id, + workspaceId, + }, + ]) + + return { folder: folderSchema.parse(newFolder) } + } + ) diff --git a/apps/builder/src/features/folders/api/deleteFolder.ts b/apps/builder/src/features/folders/api/deleteFolder.ts new file mode 100644 index 000000000..667fa7f06 --- /dev/null +++ b/apps/builder/src/features/folders/api/deleteFolder.ts @@ -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 } + }) diff --git a/apps/builder/src/features/folders/api/getFolder.ts b/apps/builder/src/features/folders/api/getFolder.ts new file mode 100644 index 000000000..aad43238c --- /dev/null +++ b/apps/builder/src/features/folders/api/getFolder.ts @@ -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 } + }) diff --git a/apps/builder/src/features/folders/api/listFolders.ts b/apps/builder/src/features/folders/api/listFolders.ts new file mode 100644 index 000000000..9d4f0acde --- /dev/null +++ b/apps/builder/src/features/folders/api/listFolders.ts @@ -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 } + }) diff --git a/apps/builder/src/features/folders/api/router.ts b/apps/builder/src/features/folders/api/router.ts new file mode 100644 index 000000000..84eeaa2c2 --- /dev/null +++ b/apps/builder/src/features/folders/api/router.ts @@ -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, +}) diff --git a/apps/builder/src/features/folders/api/updateFolder.ts b/apps/builder/src/features/folders/api/updateFolder.ts new file mode 100644 index 000000000..8f846e38a --- /dev/null +++ b/apps/builder/src/features/folders/api/updateFolder.ts @@ -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) } + } + ) diff --git a/apps/builder/src/features/folders/components/FolderButton.tsx b/apps/builder/src/features/folders/components/FolderButton.tsx index e152d9a2c..59b37f05f 100644 --- a/apps/builder/src/features/folders/components/FolderButton.tsx +++ b/apps/builder/src/features/folders/components/FolderButton.tsx @@ -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 = ({ } title={`Delete ${folder.name}?`} - onConfirm={onDeleteClick} + onConfirm={() => + deleteFolder({ + workspaceId: folder.workspaceId, + folderId: folder.id, + }) + } confirmButtonColor="red" /> diff --git a/apps/builder/src/features/folders/components/FolderContent.tsx b/apps/builder/src/features/folders/components/FolderContent.tsx index 03bedf676..930ccbffa 100644 --- a/apps/builder/src/features/folders/components/FolderContent.tsx +++ b/apps/builder/src/features/folders/components/FolderContent.tsx @@ -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 ( - + {folder?.name} @@ -197,10 +185,8 @@ export const FolderContent = ({ folder }: Props) => { handleFolderDeleted(folder.id)} - onFolderRenamed={(newName: string) => - handleFolderRenamed(folder.id, newName) - } + onFolderDeleted={refetchFolders} + onFolderRenamed={() => refetchFolders()} /> ))} {isTypebotLoading && } @@ -209,7 +195,7 @@ export const FolderContent = ({ folder }: Props) => { ))} diff --git a/apps/builder/src/features/folders/components/FolderPage.tsx b/apps/builder/src/features/folders/components/FolderPage.tsx index c357c3671..9c4b301e0 100644 --- a/apps/builder/src/features/folders/components/FolderPage.tsx +++ b/apps/builder/src/features/folders/components/FolderPage.tsx @@ -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 ( diff --git a/apps/builder/src/features/folders/hooks/useFolder.ts b/apps/builder/src/features/folders/hooks/useFolder.ts deleted file mode 100644 index 04d20e829..000000000 --- a/apps/builder/src/features/folders/hooks/useFolder.ts +++ /dev/null @@ -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, - } -} diff --git a/apps/builder/src/features/folders/hooks/useFolders.ts b/apps/builder/src/features/folders/hooks/useFolders.ts deleted file mode 100644 index 8de976f85..000000000 --- a/apps/builder/src/features/folders/hooks/useFolders.ts +++ /dev/null @@ -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, - } -} diff --git a/apps/builder/src/features/folders/queries/createFolderQuery.ts b/apps/builder/src/features/folders/queries/createFolderQuery.ts deleted file mode 100644 index ff4d0562e..000000000 --- a/apps/builder/src/features/folders/queries/createFolderQuery.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { DashboardFolder } from '@typebot.io/prisma' -import { sendRequest } from '@typebot.io/lib' - -export const createFolderQuery = async ( - workspaceId: string, - folder: Pick -) => - sendRequest({ - url: `/api/folders`, - method: 'POST', - body: { ...folder, workspaceId }, - }) diff --git a/apps/builder/src/features/folders/queries/deleteFolderQuery.ts b/apps/builder/src/features/folders/queries/deleteFolderQuery.ts deleted file mode 100644 index 0371d76f6..000000000 --- a/apps/builder/src/features/folders/queries/deleteFolderQuery.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { sendRequest } from '@typebot.io/lib' - -export const deleteFolderQuery = async (id: string) => - sendRequest({ - url: `/api/folders/${id}`, - method: 'DELETE', - }) diff --git a/apps/builder/src/features/folders/queries/updateFolderQuery.ts b/apps/builder/src/features/folders/queries/updateFolderQuery.ts deleted file mode 100644 index db113030c..000000000 --- a/apps/builder/src/features/folders/queries/updateFolderQuery.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { DashboardFolder } from '@typebot.io/prisma' -import { sendRequest } from '@typebot.io/lib' - -export const updateFolderQuery = async ( - id: string, - folder: Partial -) => - sendRequest({ - url: `/api/folders/${id}`, - method: 'PATCH', - body: folder, - }) diff --git a/apps/builder/src/features/telemetry/api/router.ts b/apps/builder/src/features/telemetry/api/router.ts new file mode 100644 index 000000000..022e3bc31 --- /dev/null +++ b/apps/builder/src/features/telemetry/api/router.ts @@ -0,0 +1,6 @@ +import { router } from '@/helpers/server/trpc' +import { trackClientEvents } from './trackClientEvents' + +export const telemetryRouter = router({ + trackClientEvents, +}) diff --git a/apps/builder/src/features/telemetry/api/trackClientEvents.ts b/apps/builder/src/features/telemetry/api/trackClientEvents.ts new file mode 100644 index 000000000..981b98fcc --- /dev/null +++ b/apps/builder/src/features/telemetry/api/trackClientEvents.ts @@ -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' } + }) diff --git a/apps/builder/src/features/telemetry/helpers/parseTypebotPublishEvents.ts b/apps/builder/src/features/telemetry/helpers/parseTypebotPublishEvents.ts new file mode 100644 index 000000000..33510c1a2 --- /dev/null +++ b/apps/builder/src/features/telemetry/helpers/parseTypebotPublishEvents.ts @@ -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 + 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 +} diff --git a/apps/builder/src/features/telemetry/helpers/trackAnalyticsPageView.ts b/apps/builder/src/features/telemetry/helpers/trackAnalyticsPageView.ts new file mode 100644 index 000000000..19ca6e5a1 --- /dev/null +++ b/apps/builder/src/features/telemetry/helpers/trackAnalyticsPageView.ts @@ -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, + }, + ]) +} diff --git a/apps/builder/src/features/theme/components/general/GeneralSettings.tsx b/apps/builder/src/features/theme/components/general/GeneralSettings.tsx index 8a4dbaf03..a65963259 100644 --- a/apps/builder/src/features/theme/components/general/GeneralSettings.tsx +++ b/apps/builder/src/features/theme/components/general/GeneralSettings.tsx @@ -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) } diff --git a/apps/builder/src/features/typebot/api/publishTypebot.ts b/apps/builder/src/features/typebot/api/publishTypebot.ts index 8b3f665e7..5e086ab15 100644 --- a/apps/builder/src/features/typebot/api/publishTypebot.ts +++ b/apps/builder/src/features/typebot/api/publishTypebot.ts @@ -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, diff --git a/apps/builder/src/helpers/server/routers/internalRouter.ts b/apps/builder/src/helpers/server/routers/internalRouter.ts index 4857c7465..bb0154624 100644 --- a/apps/builder/src/helpers/server/routers/internalRouter.ts +++ b/apps/builder/src/helpers/server/routers/internalRouter.ts @@ -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 diff --git a/apps/builder/src/helpers/server/routers/publicRouter.ts b/apps/builder/src/helpers/server/routers/publicRouter.ts index fd7b4065a..d714c4aa3 100644 --- a/apps/builder/src/helpers/server/routers/publicRouter.ts +++ b/apps/builder/src/helpers/server/routers/publicRouter.ts @@ -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 diff --git a/apps/builder/src/pages/api/folders.ts b/apps/builder/src/pages/api/folders.ts index ed81fd86d..e37962fa5 100644 --- a/apps/builder/src/pages/api/folders.ts +++ b/apps/builder/src/pages/api/folders.ts @@ -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) diff --git a/apps/builder/src/pages/api/folders/[id].ts b/apps/builder/src/pages/api/folders/[id].ts index d3c9fe85e..c5b84dc60 100644 --- a/apps/builder/src/pages/api/folders/[id].ts +++ b/apps/builder/src/pages/api/folders/[id].ts @@ -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) diff --git a/apps/builder/src/pages/typebots/[typebotId]/results/analytics.tsx b/apps/builder/src/pages/typebots/[typebotId]/results/analytics.tsx index e33078959..509e0837b 100644 --- a/apps/builder/src/pages/typebots/[typebotId]/results/analytics.tsx +++ b/apps/builder/src/pages/typebots/[typebotId]/results/analytics.tsx @@ -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 diff --git a/apps/docs/api-reference/folder/create.mdx b/apps/docs/api-reference/folder/create.mdx new file mode 100644 index 000000000..722fa067a --- /dev/null +++ b/apps/docs/api-reference/folder/create.mdx @@ -0,0 +1,4 @@ +--- +title: 'Create a folder' +openapi: POST /v1/folders +--- diff --git a/apps/docs/api-reference/folder/delete.mdx b/apps/docs/api-reference/folder/delete.mdx new file mode 100644 index 000000000..669b3ffe4 --- /dev/null +++ b/apps/docs/api-reference/folder/delete.mdx @@ -0,0 +1,4 @@ +--- +title: 'Delete a folder' +openapi: DELETE /v1/folders/{folderId} +--- diff --git a/apps/docs/api-reference/folder/get.mdx b/apps/docs/api-reference/folder/get.mdx new file mode 100644 index 000000000..d62e8261a --- /dev/null +++ b/apps/docs/api-reference/folder/get.mdx @@ -0,0 +1,4 @@ +--- +title: 'Get a folder' +openapi: GET /v1/folders/{folderId} +--- diff --git a/apps/docs/api-reference/folder/list.mdx b/apps/docs/api-reference/folder/list.mdx new file mode 100644 index 000000000..c89009fdd --- /dev/null +++ b/apps/docs/api-reference/folder/list.mdx @@ -0,0 +1,4 @@ +--- +title: 'List folders' +openapi: GET /v1/folders +--- diff --git a/apps/docs/api-reference/folder/update.mdx b/apps/docs/api-reference/folder/update.mdx new file mode 100644 index 000000000..42e487080 --- /dev/null +++ b/apps/docs/api-reference/folder/update.mdx @@ -0,0 +1,4 @@ +--- +title: 'Update a folder' +openapi: PATCH /v1/folders/{folderId} +--- diff --git a/apps/docs/mint.json b/apps/docs/mint.json index 3203cad59..8105fd605 100644 --- a/apps/docs/mint.json +++ b/apps/docs/mint.json @@ -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": [ diff --git a/apps/docs/openapi/builder.json b/apps/docs/openapi/builder.json index b3e82a176..b45282bb3 100644 --- a/apps/docs/openapi/builder.json +++ b/apps/docs/openapi/builder.json @@ -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": { diff --git a/packages/schemas/features/folder.ts b/packages/schemas/features/folder.ts new file mode 100644 index 000000000..cc98ced3e --- /dev/null +++ b/packages/schemas/features/folder.ts @@ -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 + +export type Folder = z.infer diff --git a/packages/schemas/features/telemetry.ts b/packages/schemas/features/telemetry.ts index 7c8352d02..93a527d17 100644 --- a/packages/schemas/features/telemetry.ts +++ b/packages/schemas/features/telemetry.ts @@ -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 diff --git a/packages/schemas/index.ts b/packages/schemas/index.ts index 126564fa2..ae316d82f 100644 --- a/packages/schemas/index.ts +++ b/packages/schemas/index.ts @@ -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'