2
0

⚗️ Implement bot v2 MVP (#194)

Closes #190
This commit is contained in:
Baptiste Arnaud
2022-12-22 17:02:34 +01:00
committed by GitHub
parent e55823e011
commit 1a3869ae6d
202 changed files with 8060 additions and 1152 deletions

View File

@ -14,7 +14,9 @@ Sentry.init({
hint?.event.target.innerText
}`
}
} catch (e) {}
} catch (e) {
/* empty */
}
return breadcrumb
},
})

View File

@ -38,6 +38,7 @@ export const SearchableDropdown = ({
const { onOpen, onClose, isOpen } = useDisclosure()
const [inputValue, setInputValue] = useState(selectedItem ?? '')
const debounced = useDebouncedCallback(
// eslint-disable-next-line @typescript-eslint/no-empty-function
onValueChange ? onValueChange : () => {},
env('E2E_TEST') === 'true' ? 0 : debounceTimeout
)

View File

@ -2,7 +2,6 @@ import {
useDisclosure,
Flex,
Popover,
PopoverTrigger,
Input,
PopoverContent,
Button,

View File

@ -24,6 +24,7 @@ const userContext = createContext<{
currentWorkspaceId?: string
updateUser: (newUser: Partial<User>) => void
saveUser: (newUser?: Partial<User>) => Promise<void>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})

View File

@ -11,7 +11,7 @@ import {
Text,
} from '@chakra-ui/react'
import { UploadIcon } from '@/components/icons'
import React, { ChangeEvent, useState } from 'react'
import React, { ChangeEvent } from 'react'
import { isDefined } from 'utils'
import { ApiTokensList } from './ApiTokensList'
import { UploadButton } from '@/components/ImageUploadContent/UploadButton'

View File

@ -46,10 +46,14 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
flex="1"
typebot={publishedTypebot}
onUnlockProPlanClick={onOpen}
answersCounts={[
{ ...answersCounts[0], totalAnswers: stats?.totalStarts },
...answersCounts?.slice(1),
]}
answersCounts={
answersCounts
? [
{ ...answersCounts[0], totalAnswers: stats?.totalStarts },
...answersCounts.slice(1),
]
: []
}
/>
</GroupsCoordinatesProvider>
</GraphProvider>

View File

@ -115,7 +115,7 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
onCredentialsSelect={handleCredentialsSelect}
onCreateNewClick={onOpen}
defaultCredentialLabel={env('SMTP_FROM')
?.match(/\<(.*)\>/)
?.match(/<(.*)>/)
?.pop()}
refreshDropdownKey={refreshCredentialsKey}
/>

View File

@ -1,3 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const getDeepKeys = (obj: any): string[] => {
let keys: string[] = []
for (const key in obj) {

View File

@ -8,7 +8,6 @@ import {
MenuItem,
MenuList,
SkeletonCircle,
SkeletonText,
Text,
Tag,
Flex,

View File

@ -11,7 +11,7 @@ export const useTypebots = ({
}) => {
const { data, isLoading, refetch } = trpc.typebot.listTypebots.useQuery(
{
workspaceId: workspaceId!,
workspaceId: workspaceId as string,
folderId,
},
{

View File

@ -1,10 +1,4 @@
import {
Flex,
HStack,
Text,
Tooltip,
useColorModeValue,
} from '@chakra-ui/react'
import { Flex, HStack, Tooltip, useColorModeValue } from '@chakra-ui/react'
import { DraggableBlockType } from 'models'
import { useBlockDnd } from '@/features/graph'
import React, { useEffect, useState } from 'react'

View File

@ -16,6 +16,7 @@ const editorContext = createContext<{
setRightPanel: Dispatch<SetStateAction<RightPanel | undefined>>
startPreviewAtGroup: string | undefined
setStartPreviewAtGroup: Dispatch<SetStateAction<string | undefined>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})

View File

@ -88,6 +88,7 @@ const typebotContext = createContext<
ItemsActions &
VariablesActions &
EdgesActions
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
>({})

View File

@ -14,6 +14,7 @@ const typebotDndContext = createContext<{
setDraggedTypebot: Dispatch<SetStateAction<TypebotInDashboard | undefined>>
mouseOverFolderId?: string | null
setMouseOverFolderId: Dispatch<SetStateAction<string | undefined | null>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
}>({})

View File

@ -103,7 +103,7 @@ export const FolderContent = ({ folder }: Props) => {
if (newFolder) mutateFolders({ folders: [...folders, newFolder] })
}
const handleTypebotDeleted = (deletedId: string) => {
const handleTypebotDeleted = () => {
if (!typebots) return
refetchTypebots()
}
@ -206,7 +206,7 @@ export const FolderContent = ({ folder }: Props) => {
<TypebotButton
key={typebot.id.toString()}
typebot={typebot}
onTypebotDeleted={() => handleTypebotDeleted(typebot.id)}
onTypebotDeleted={handleTypebotDeleted}
onMouseDown={handleMouseDown(typebot)}
/>
))}

View File

@ -6,7 +6,6 @@ import {
useColorModeValue,
VStack,
} from '@chakra-ui/react'
import { GlobeIcon, ToolIcon } from '@/components/icons'
import { TypebotInDashboard } from '@/features/dashboard'
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'

View File

@ -1,7 +1,7 @@
import { Coordinates, useGraph, useGroupsCoordinates } from '../../providers'
import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { Edge as EdgeProps } from 'models'
import { color, Portal, useColorMode, useDisclosure } from '@chakra-ui/react'
import { Portal, useColorMode, useDisclosure } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor'
import { EdgeMenu } from './EdgeMenu'
import { colors } from '@/lib/theme'

View File

@ -1,4 +1,4 @@
import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
import { useEventListener, Stack, Portal } from '@chakra-ui/react'
import { DraggableBlock, DraggableBlockType, Block } from 'models'
import {
computeNearestPlaceholderIndex,

View File

@ -27,6 +27,7 @@ const graphDndContext = createContext<{
setMouseOverGroup: Dispatch<SetStateAction<NodeInfo | undefined>>
mouseOverBlock?: NodeInfo
setMouseOverBlock: Dispatch<SetStateAction<NodeInfo | undefined>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})

View File

@ -80,6 +80,7 @@ const graphContext = createContext<{
isReadOnly: boolean
focusedGroupId?: string
setFocusedGroupId: Dispatch<SetStateAction<string | undefined>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({
graphPosition: graphPositionDefaultValue({ x: 0, y: 0 }),

View File

@ -12,6 +12,7 @@ import { GroupsCoordinates, Coordinates } from './GraphProvider'
const groupsCoordinatesContext = createContext<{
groupsCoordinates: GroupsCoordinates
updateGroupCoordinates: (groupId: string, newCoord: Coordinates) => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})

View File

@ -17,6 +17,7 @@ const resultsContext = createContext<{
onDeleteResults: (totalResultsDeleted: number) => void
fetchNextPage: () => void
refetchResults: () => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})

View File

@ -2,10 +2,12 @@ import { Checkbox, Flex } from '@chakra-ui/react'
import React from 'react'
const TableCheckBox = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ indeterminate, checked, ...rest }: any,
ref: React.LegacyRef<HTMLInputElement>
) => {
const defaultRef = React.useRef()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedRef: any = ref || defaultRef
return (

View File

@ -3,7 +3,6 @@ import {
Checkbox,
Flex,
Skeleton,
useColorMode,
useColorModeValue,
} from '@chakra-ui/react'
import React from 'react'

View File

@ -14,7 +14,7 @@ test.describe.parallel('Templates page', () => {
test('From file should import correctly', async ({ page }) => {
await page.goto('/typebots/create')
await page.waitForTimeout(1000)
await page.waitForTimeout(2000)
await page.setInputFiles(
'input[type="file"]',
getTestAsset('typebots/singleChoiceTarget.json')

View File

@ -50,7 +50,9 @@ test.describe.parallel('Theme page', () => {
await importTypebotInDatabase(getTestAsset('typebots/theme.json'), {
id: typebotId,
})
} catch {}
} catch {
/* empty */
}
await page.goto(`/typebots/${typebotId}/theme`)
await expect(

View File

@ -1,7 +1,6 @@
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
@ -26,6 +25,7 @@ const workspaceContext = createContext<{
updateWorkspace: (updates: { icon?: string; name?: string }) => void
deleteCurrentWorkspace: () => Promise<void>
refreshWorkspace: () => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
@ -59,12 +59,12 @@ export const WorkspaceProvider = ({
)
const { data: workspaceData } = trpc.workspace.getWorkspace.useQuery(
{ workspaceId: workspaceId! },
{ workspaceId: workspaceId as string },
{ enabled: !!workspaceId }
)
const { data: membersData } = trpc.workspace.listMembersInWorkspace.useQuery(
{ workspaceId: workspaceId! },
{ workspaceId: workspaceId as string },
{ enabled: !!workspaceId }
)

View File

@ -15,7 +15,7 @@ import {
UsersIcon,
} from '@/components/icons'
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
import { GraphNavigation, User, Workspace, WorkspaceRole } from 'db'
import { User, Workspace, WorkspaceRole } from 'db'
import { useState } from 'react'
import { MembersList } from './MembersList'
import { WorkspaceSettingsForm } from './WorkspaceSettingsForm'
@ -149,13 +149,7 @@ export const WorkspaceSettingsModal = ({
{isOpen && (
<Flex flex="1" p="10">
<SettingsContent
tab={selectedTab}
onClose={onClose}
defaultGraphNavigation={
user.graphNavigation ?? GraphNavigation.TRACKPAD
}
/>
<SettingsContent tab={selectedTab} onClose={onClose} />
</Flex>
)}
</ModalContent>
@ -165,11 +159,9 @@ export const WorkspaceSettingsModal = ({
const SettingsContent = ({
tab,
defaultGraphNavigation,
onClose,
}: {
tab: SettingsTab
defaultGraphNavigation: GraphNavigation
onClose: () => void
}) => {
switch (tab) {

View File

@ -17,6 +17,7 @@
*/
import * as Sentry from '@sentry/nextjs'
import { NextPageContext } from 'next'
import NextErrorComponent from 'next/error'
const CustomErrorComponent = (props: {
@ -31,7 +32,7 @@ const CustomErrorComponent = (props: {
return <NextErrorComponent statusCode={props.statusCode} />
}
CustomErrorComponent.getInitialProps = async (contextData: any) => {
CustomErrorComponent.getInitialProps = async (contextData: NextPageContext) => {
// In case this is running in a serverless function, await this in order to give Sentry
// time to send the error before the lambda exits
await Sentry.captureUnderscoreErrorException(contextData)

View File

@ -13,7 +13,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const domain = req.query.domain as string
try {
await deleteDomainOnVercel(domain)
} catch {}
} catch {
/* empty */
}
const customDomains = await prisma.customDomain.delete({
where: { name: domain },
})

View File

@ -1,4 +1,4 @@
import { captureException, withSentry } from '@sentry/nextjs'
import { captureException } from '@sentry/nextjs'
import { SmtpCredentialsData } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { createTransport } from 'nodemailer'

View File

@ -2,7 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { drive } from '@googleapis/drive'
import { getAuthenticatedGoogleClient } from '@/lib/googleSheets'
import { badRequest, methodNotAllowed, notAuthenticated } from 'utils/api'
import { setUser, withSentry } from '@sentry/nextjs'
import { setUser } from '@sentry/nextjs'
import { getAuthenticatedUser } from '@/features/auth/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {

View File

@ -3,7 +3,7 @@ import { GoogleSpreadsheet } from 'google-spreadsheet'
import { getAuthenticatedGoogleClient } from '@/lib/googleSheets'
import { isDefined } from 'utils'
import { badRequest, methodNotAllowed, notAuthenticated } from 'utils/api'
import { withSentry, setUser } from '@sentry/nextjs'
import { setUser } from '@sentry/nextjs'
import { getAuthenticatedUser } from '@/features/auth/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {

View File

@ -5,6 +5,7 @@ import Cors from 'micro-cors'
import { buffer } from 'micro'
import prisma from '@/lib/prisma'
import { Plan } from 'db'
import { RequestHandler } from 'next/dist/server/next'
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_WEBHOOK_SECRET)
throw new Error('STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET missing')
@ -127,4 +128,4 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
return methodNotAllowed(res)
}
export default cors(webhookHandler as any)
export default cors(webhookHandler as RequestHandler)

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@ The Builder API is about what you can edit on https://app.typebot.io (i.e. creat
## Chat
:::caution
You should not use it in production. This API is experimental at the moment and will be heavily modified with time.
You should not use it in production. This API is experimental at the moment and will be changed without notice.
:::
The Chat API allows you to execute (chat) with a typebot.

View File

@ -14,6 +14,7 @@
"dependencies": {
"@sentry/nextjs": "7.27.0",
"@trpc/server": "10.5.0",
"@typebot.io/react": "workspace:*",
"aws-sdk": "2.1277.0",
"bot-engine": "workspace:*",
"cors": "2.8.5",
@ -24,6 +25,7 @@
"next": "13.0.7",
"nextjs-cors": "^2.1.2",
"nodemailer": "6.8.0",
"phone": "^3.1.31",
"qs": "6.11.0",
"react": "18.2.0",
"react-dom": "18.2.0",
@ -42,8 +44,8 @@
"@types/qs": "6.9.7",
"@types/react": "18.0.26",
"@types/sanitize-html": "2.8.0",
"dotenv": "16.0.3",
"cuid": "2.1.8",
"dotenv": "16.0.3",
"emails": "workspace:*",
"eslint": "8.30.0",
"eslint-config-custom": "workspace:*",

View File

@ -8,6 +8,10 @@ import { ErrorPage } from './ErrorPage'
import { createResultQuery, updateResultQuery } from '@/features/results'
import { upsertAnswerQuery } from '@/features/answers'
import { gtmBodyElement } from '@/lib/google-tag-manager'
import {
getExistingResultFromSession,
setResultInSession,
} from '@/utils/sessionStorage'
export type TypebotPageProps = {
publishedTypebot: Omit<PublicTypebot, 'createdAt' | 'updatedAt'> & {
@ -18,8 +22,6 @@ export type TypebotPageProps = {
customHeadCode: string | null
}
const sessionStorageKey = 'resultId'
export const TypebotPage = ({
publishedTypebot,
isIE,
@ -153,15 +155,3 @@ export const TypebotPage = ({
</div>
)
}
const getExistingResultFromSession = () => {
try {
return sessionStorage.getItem(sessionStorageKey)
} catch {}
}
const setResultInSession = (resultId: string) => {
try {
return sessionStorage.setItem(sessionStorageKey, resultId)
} catch {}
}

View File

@ -0,0 +1,110 @@
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
import {
getExistingResultFromSession,
setResultInSession,
} from '@/utils/sessionStorage'
import Bot from '@typebot.io/react'
import { BackgroundType, InitialChatReply, Typebot } from 'models'
import { useRouter } from 'next/router'
import { useCallback, useEffect, useState } from 'react'
import { ErrorPage } from './ErrorPage'
import { SEO } from './Seo'
export type TypebotPageV2Props = {
url: string
typebot: Pick<
Typebot,
'settings' | 'theme' | 'id' | 'name' | 'isClosed' | 'isArchived'
>
}
let hasInitializedChat = false
export const TypebotPageV2 = ({ url, typebot }: TypebotPageV2Props) => {
const { asPath, push } = useRouter()
const [initialChatReply, setInitialChatReply] = useState<InitialChatReply>()
const [error, setError] = useState<Error | undefined>(undefined)
const background = typebot.theme.general.background
const clearQueryParamsIfNecessary = useCallback(() => {
const hasQueryParams = asPath.includes('?')
if (
!hasQueryParams ||
!(typebot.settings.general.isHideQueryParamsEnabled ?? true)
)
return
push(asPath.split('?')[0], undefined, { shallow: true })
}, [asPath, push, typebot.settings.general.isHideQueryParamsEnabled])
useEffect(() => {
clearQueryParamsIfNecessary()
}, [clearQueryParamsIfNecessary])
useEffect(() => {
if (hasInitializedChat) return
hasInitializedChat = true
const prefilledVariables = extractPrefilledVariables()
const existingResultId = getExistingResultFromSession() ?? undefined
getInitialChatReplyQuery({
typebotId: typebot.id,
resultId:
typebot.settings.general.isNewResultOnRefreshEnabled ?? false
? undefined
: existingResultId,
prefilledVariables,
}).then(({ data, error }) => {
if (error && 'code' in error && error.code === 'FORBIDDEN') {
setError(new Error('This bot is now closed.'))
return
}
if (!data) return setError(new Error("Couldn't initiate the chat"))
setInitialChatReply(data)
setResultInSession(data.resultId)
})
}, [
initialChatReply,
typebot.id,
typebot.settings.general.isNewResultOnRefreshEnabled,
])
if (error) {
return <ErrorPage error={error} />
}
return (
<div
style={{
height: '100vh',
// Set background color to avoid SSR flash
backgroundColor:
background.type === BackgroundType.COLOR
? background.content
: 'white',
}}
>
<SEO
url={url}
typebotName={typebot.name}
metadata={typebot.settings.metadata}
/>
{initialChatReply && (
<Bot.Standard
typebotId={typebot.id}
initialChatReply={initialChatReply}
/>
)}
</div>
)
}
const extractPrefilledVariables = () => {
const urlParams = new URLSearchParams(location.search)
const prefilledVariables: { [key: string]: string } = {}
urlParams.forEach((value, key) => {
prefilledVariables[key] = value
})
return prefilledVariables
}

View File

@ -1,4 +1,4 @@
const emailRegex =
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
export const validateEmail = (email: string) => emailRegex.test(email)

View File

@ -0,0 +1,117 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { parse } from 'papaparse'
import { readFileSync } from 'fs'
import { isDefined } from 'utils'
import {
createWorkspaces,
importTypebotInDatabase,
injectFakeResults,
} from 'utils/playwright/databaseActions'
import { getTestAsset } from '@/test/utils/playwright'
import { Plan } from 'db'
const THREE_GIGABYTES = 3 * 1024 * 1024 * 1024
test('should work as expected', async ({ page, browser }) => {
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await page.goto(`/next/${typebotId}-public`)
await page
.locator(`input[type="file"]`)
.setInputFiles([
getTestAsset('typebots/api.json'),
getTestAsset('typebots/fileUpload.json'),
getTestAsset('typebots/hugeGroup.json'),
])
await expect(page.locator(`text="3"`)).toBeVisible()
await page.locator('text="Upload 3 files"').click()
await expect(page.locator(`text="3 files uploaded"`)).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.getByRole('link', { name: 'api.json' })).toHaveAttribute(
'href',
/.+\/api\.json/
)
await expect(
page.getByRole('link', { name: 'fileUpload.json' })
).toHaveAttribute('href', /.+\/fileUpload\.json/)
await expect(
page.getByRole('link', { name: 'hugeGroup.json' })
).toHaveAttribute('href', /.+\/hugeGroup\.json/)
await page.click('[data-testid="checkbox"] >> nth=0')
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('text="Export"').click(),
])
const downloadPath = await download.path()
expect(downloadPath).toBeDefined()
const file = readFileSync(downloadPath as string).toString()
const { data } = parse(file)
expect(data).toHaveLength(2)
expect((data[1] as unknown[])[1]).toContain(process.env.S3_ENDPOINT)
const urls = (
await Promise.all(
[
page.getByRole('link', { name: 'api.json' }),
page.getByRole('link', { name: 'fileUpload.json' }),
page.getByRole('link', { name: 'hugeGroup.json' }),
].map((elem) => elem.getAttribute('href'))
)
).filter(isDefined)
const page2 = await browser.newPage()
await page2.goto(urls[0])
await expect(page2.locator('pre')).toBeVisible()
await page.locator('button >> text="Delete"').click()
await page.locator('button >> text="Delete" >> nth=1').click()
await expect(page.locator('text="api.json"')).toBeHidden()
await page2.goto(urls[0])
await expect(page2.locator('pre')).toBeHidden()
})
test.describe('Storage limit is reached', () => {
const typebotId = cuid()
const workspaceId = cuid()
test.beforeAll(async () => {
await createWorkspaces([{ id: workspaceId, plan: Plan.STARTER }])
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
workspaceId,
})
await injectFakeResults({
typebotId,
count: 20,
fakeStorage: THREE_GIGABYTES,
})
})
test("shouldn't upload anything if limit has been reached", async ({
page,
}) => {
await page.goto(`/next/${typebotId}-public`)
await page
.locator(`input[type="file"]`)
.setInputFiles([
getTestAsset('typebots/api.json'),
getTestAsset('typebots/fileUpload.json'),
getTestAsset('typebots/hugeGroup.json'),
])
await expect(page.locator(`text="3"`)).toBeVisible()
await page.locator('text="Upload 3 files"').click()
await expect(page.locator(`text="3 files uploaded"`)).toBeVisible()
await page.evaluate(() =>
window.localStorage.setItem('workspaceId', 'starterWorkspace')
)
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text="150%"')).toBeVisible()
await expect(page.locator('text="api.json"')).toBeHidden()
})
})

View File

@ -0,0 +1 @@
export * from './utils'

View File

@ -0,0 +1,128 @@
import { parseVariables } from '@/features/variables'
import prisma from '@/lib/prisma'
import { TRPCError } from '@trpc/server'
import {
PaymentInputOptions,
PaymentInputRuntimeOptions,
SessionState,
StripeCredentialsData,
} from 'models'
import Stripe from 'stripe'
import { decrypt } from 'utils/api/encryption'
export const computePaymentInputRuntimeOptions =
(state: SessionState) => (options: PaymentInputOptions) =>
createStripePaymentIntent(state)(options)
const createStripePaymentIntent =
(state: SessionState) =>
async (options: PaymentInputOptions): Promise<PaymentInputRuntimeOptions> => {
const {
isPreview,
typebot: { variables },
} = state
if (!options.credentialsId)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Missing credentialsId',
})
const stripeKeys = await getStripeInfo(options.credentialsId)
if (!stripeKeys)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Credentials not found',
})
const stripe = new Stripe(
isPreview && stripeKeys?.test?.secretKey
? stripeKeys.test.secretKey
: stripeKeys.live.secretKey,
{ apiVersion: '2022-11-15' }
)
const amount =
Number(parseVariables(variables)(options.amount)) *
(isZeroDecimalCurrency(options.currency) ? 1 : 100)
if (isNaN(amount))
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'Could not parse amount, make sure your block is configured correctly',
})
// Create a PaymentIntent with the order amount and currency
const receiptEmail = parseVariables(variables)(
options.additionalInformation?.email
)
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: options.currency,
receipt_email: receiptEmail === '' ? undefined : receiptEmail,
automatic_payment_methods: {
enabled: true,
},
})
if (!paymentIntent.client_secret)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Could not create payment intent',
})
return {
paymentIntentSecret: paymentIntent.client_secret,
publicKey:
isPreview && stripeKeys.test?.publicKey
? stripeKeys.test.publicKey
: stripeKeys.live.publicKey,
amountLabel: `${
amount / (isZeroDecimalCurrency(options.currency) ? 1 : 100)
}${currencySymbols[options.currency] ?? ` ${options.currency}`}`,
}
}
const getStripeInfo = async (
credentialsId: string
): Promise<StripeCredentialsData | undefined> => {
const credentials = await prisma.credentials.findUnique({
where: { id: credentialsId },
})
if (!credentials) return
return decrypt(credentials.data, credentials.iv) as StripeCredentialsData
}
// https://stripe.com/docs/currencies#zero-decimal
const isZeroDecimalCurrency = (currency: string) =>
[
'BIF',
'CLP',
'DJF',
'GNF',
'JPY',
'KMF',
'KRW',
'MGA',
'PYG',
'RWF',
'UGX',
'VND',
'VUV',
'XAF',
'XOF',
'XPF',
].includes(currency)
const currencySymbols: { [key: string]: string } = {
USD: '$',
EUR: '€',
CRC: '₡',
GBP: '£',
ILS: '₪',
INR: '₹',
JPY: '¥',
KRW: '₩',
NGN: '₦',
PHP: '₱',
PLN: 'zł',
PYG: '₲',
THB: '฿',
UAH: '₴',
VND: '₫',
}

View File

@ -0,0 +1 @@
export * from './computePaymentInputRuntimeOptions'

View File

@ -1 +1 @@
export { validatePhoneNumber } from './utils/validatePhoneNumber'
export * from './utils'

View File

@ -0,0 +1,4 @@
import phone from 'phone'
export const formatPhoneNumber = (phoneNumber: string) =>
phone(phoneNumber).phoneNumber

View File

@ -0,0 +1,2 @@
export * from './formatPhoneNumber'
export * from './validatePhoneNumber'

View File

@ -1,4 +1,4 @@
const phoneRegex = /^\+?[0-9]{6,}$/
import { phone } from 'phone'
export const validatePhoneNumber = (phoneNumber: string) =>
phoneRegex.test(phoneNumber)
phone(phoneNumber).isValid

View File

@ -0,0 +1,30 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { createTypebots } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { defaultChatwootOptions, IntegrationBlockType } from 'models'
const typebotId = cuid()
const chatwootTestWebsiteToken = 'tueXiiqEmrWUCZ4NUyoR7nhE'
test('should work as expected', async ({ page }) => {
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock(
{
type: IntegrationBlockType.CHATWOOT,
options: {
...defaultChatwootOptions,
websiteToken: chatwootTestWebsiteToken,
},
},
{ withGoButton: true }
),
},
])
await page.goto(`/next/${typebotId}-public`)
await page.getByRole('button', { name: 'Go' }).click()
await expect(page.locator('#chatwoot_live_chat_widget')).toBeVisible()
})

View File

@ -1,9 +1,16 @@
import { SessionState, GoogleSheetsGetOptions, VariableWithValue } from 'models'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
import {
SessionState,
GoogleSheetsGetOptions,
VariableWithValue,
ComparisonOperators,
LogicalOperator,
} from 'models'
import { saveErrorLog } from '@/features/logs/api'
import { getAuthenticatedGoogleDoc } from './helpers'
import { parseVariables, updateVariables } from '@/features/variables'
import { isNotEmpty, byId } from 'utils'
import { updateVariables } from '@/features/variables'
import { isNotEmpty, byId, isDefined } from 'utils'
import { ExecuteIntegrationResponse } from '@/features/chat'
import type { GoogleSpreadsheetRow } from 'google-spreadsheet'
export const getRow = async (
state: SessionState,
@ -12,56 +19,51 @@ export const getRow = async (
options,
}: { outgoingEdgeId?: string; options: GoogleSheetsGetOptions }
): Promise<ExecuteIntegrationResponse> => {
const { sheetId, cellsToExtract, referenceCell } = options
const { sheetId, cellsToExtract, referenceCell, filter } = options
if (!cellsToExtract || !sheetId || !referenceCell) return { outgoingEdgeId }
const variables = state.typebot.variables
const resultId = state.result.id
const resultId = state.result?.id
const doc = await getAuthenticatedGoogleDoc({
credentialsId: options.credentialsId,
spreadsheetId: options.spreadsheetId,
})
const parsedReferenceCell = {
column: referenceCell.column,
value: parseVariables(variables)(referenceCell.value),
}
const extractingColumns = cellsToExtract
.map((cell) => cell.column)
.filter(isNotEmpty)
try {
await doc.loadInfo()
const sheet = doc.sheetsById[sheetId]
const rows = await sheet.getRows()
const row = rows.find(
(row) =>
row[parsedReferenceCell.column as string] === parsedReferenceCell.value
const filteredRows = rows.filter((row) =>
referenceCell
? row[referenceCell.column as string] === referenceCell.value
: matchFilter(row, filter)
)
if (!row) {
if (filteredRows.length === 0) {
await saveErrorLog({
resultId,
message: "Couldn't find reference cell",
})
return { outgoingEdgeId }
}
const data: { [key: string]: string } = {
...extractingColumns.reduce(
(obj, column) => ({ ...obj, [column]: row[column] }),
{}
),
}
await saveSuccessLog({
resultId,
message: 'Succesfully fetched spreadsheet data',
})
const randomIndex = Math.floor(Math.random() * filteredRows.length)
const extractingColumns = cellsToExtract
.map((cell) => cell.column)
.filter(isNotEmpty)
const selectedRow = filteredRows
.map((row) =>
extractingColumns.reduce<{ [key: string]: string }>(
(obj, column) => ({ ...obj, [column]: row[column] }),
{}
)
)
.at(randomIndex)
if (!selectedRow) return { outgoingEdgeId }
const newVariables = options.cellsToExtract.reduce<VariableWithValue[]>(
(newVariables, cell) => {
const existingVariable = variables.find(byId(cell.variableId))
const value = data[cell.column ?? ''] ?? null
const value = selectedRow[cell.column ?? ''] ?? null
if (!existingVariable) return newVariables
return [
...newVariables,
@ -87,3 +89,56 @@ export const getRow = async (
}
return { outgoingEdgeId }
}
const matchFilter = (
row: GoogleSpreadsheetRow,
filter: GoogleSheetsGetOptions['filter']
) => {
return filter.logicalOperator === LogicalOperator.AND
? filter.comparisons.every(
(comparison) =>
comparison.column &&
matchComparison(
row[comparison.column],
comparison.comparisonOperator,
comparison.value
)
)
: filter.comparisons.some(
(comparison) =>
comparison.column &&
matchComparison(
row[comparison.column],
comparison.comparisonOperator,
comparison.value
)
)
}
const matchComparison = (
inputValue?: string,
comparisonOperator?: ComparisonOperators,
value?: string
) => {
if (!inputValue || !comparisonOperator || !value) return false
switch (comparisonOperator) {
case ComparisonOperators.CONTAINS: {
return inputValue.toLowerCase().includes(value.toLowerCase())
}
case ComparisonOperators.EQUAL: {
return inputValue === value
}
case ComparisonOperators.NOT_EQUAL: {
return inputValue !== value
}
case ComparisonOperators.GREATER: {
return parseFloat(inputValue) > parseFloat(value)
}
case ComparisonOperators.LESS: {
return parseFloat(inputValue) < parseFloat(value)
}
case ComparisonOperators.IS_SET: {
return isDefined(inputValue) && inputValue.length > 0
}
}
}

View File

@ -23,16 +23,18 @@ export const insertRow = async (
await doc.loadInfo()
const sheet = doc.sheetsById[options.sheetId]
await sheet.addRow(parsedValues)
await saveSuccessLog({
resultId: result.id,
message: 'Succesfully inserted row',
})
result &&
(await saveSuccessLog({
resultId: result.id,
message: 'Succesfully inserted row',
}))
} catch (err) {
await saveErrorLog({
resultId: result.id,
message: "Couldn't fetch spreadsheet data",
details: err,
})
result &&
(await saveErrorLog({
resultId: result.id,
message: "Couldn't fetch spreadsheet data",
details: err,
}))
}
return { outgoingEdgeId }
}

View File

@ -45,16 +45,18 @@ export const updateRow = async (
rows[updatingRowIndex][key] = parsedValues[key]
}
await rows[updatingRowIndex].save()
await saveSuccessLog({
resultId: result.id,
message: 'Succesfully updated row',
})
result &&
(await saveSuccessLog({
resultId: result.id,
message: 'Succesfully updated row',
}))
} catch (err) {
await saveErrorLog({
resultId: result.id,
message: "Couldn't fetch spreadsheet data",
details: err,
})
result &&
(await saveErrorLog({
resultId: result.id,
message: "Couldn't fetch spreadsheet data",
details: err,
}))
}
return { outgoingEdgeId }
}

View File

@ -26,7 +26,7 @@ export const executeSendEmailBlock = async (
const { variables } = typebot
await sendEmail({
typebotId: typebot.id,
resultId: result.id,
resultId: result?.id,
credentialsId: options.credentialsId,
recipients: options.recipients.map(parseVariables(variables)),
subject: parseVariables(variables)(options.subject ?? ''),
@ -59,7 +59,7 @@ const sendEmail = async ({
fileUrls,
}: SendEmailOptions & {
typebotId: string
resultId: string
resultId?: string
fileUrls?: string
}) => {
const { name: replyToName } = parseEmailRecipient(replyTo)
@ -114,7 +114,7 @@ const sendEmail = async ({
...emailBody,
}
try {
const info = await transporter.sendMail(email)
await transporter.sendMail(email)
await saveSuccessLog({
resultId,
message: 'Email successfully sent',
@ -169,7 +169,7 @@ const getEmailBody = async ({
resultId,
}: {
typebotId: string
resultId: string
resultId?: string
} & Pick<SendEmailOptions, 'isCustomBody' | 'isBodyCode' | 'body'>): Promise<
{ html?: string; text?: string } | undefined
> => {

View File

@ -0,0 +1,40 @@
import test, { expect } from '@playwright/test'
import { createSmtpCredentials } from '../../../../test/utils/databaseActions'
import cuid from 'cuid'
import { SmtpCredentialsData } from 'models'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { getTestAsset } from '@/test/utils/playwright'
const mockSmtpCredentials: SmtpCredentialsData = {
from: {
email: 'marley.cummings@ethereal.email',
name: 'Marley Cummings',
},
host: 'smtp.ethereal.email',
port: 587,
username: 'marley.cummings@ethereal.email',
password: 'E5W1jHbAmv5cXXcut2',
}
test.beforeAll(async () => {
try {
const credentialsId = 'send-email-credentials'
await createSmtpCredentials(credentialsId, mockSmtpCredentials)
} catch (err) {
console.error(err)
}
})
test('should send an email', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/sendEmail.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await page.goto(`/next/${typebotId}-public`)
await page.locator('text=Send email').click()
await expect(page.getByText('Email sent!')).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await page.click('text="See logs"')
await expect(page.locator('text="Email successfully sent"')).toBeVisible()
})

View File

@ -35,37 +35,46 @@ export const executeWebhookBlock = async (
where: { id: block.webhookId },
})) as Webhook | null
if (!webhook) {
await saveErrorLog({
resultId: result.id,
message: `Couldn't find webhook`,
})
result &&
(await saveErrorLog({
resultId: result.id,
message: `Couldn't find webhook`,
}))
return { outgoingEdgeId: block.outgoingEdgeId }
}
const preparedWebhook = prepareWebhookAttributes(webhook, block.options)
const resultValues = await getResultValues(result.id)
const resultValues = result && (await getResultValues(result.id))
if (!resultValues) return { outgoingEdgeId: block.outgoingEdgeId }
const webhookResponse = await executeWebhook({ typebot })(
preparedWebhook,
typebot.variables,
block.groupId,
resultValues,
result.id
result?.id
)
const status = webhookResponse.statusCode.toString()
const isError = status.startsWith('4') || status.startsWith('5')
if (isError) {
await saveErrorLog({
resultId: result.id,
message: `Webhook returned error: ${webhookResponse.data}`,
details: JSON.stringify(webhookResponse.data, null, 2).substring(0, 1000),
})
result &&
(await saveErrorLog({
resultId: result.id,
message: `Webhook returned error: ${webhookResponse.data}`,
details: JSON.stringify(webhookResponse.data, null, 2).substring(
0,
1000
),
}))
} else {
await saveSuccessLog({
resultId: result.id,
message: `Webhook returned success: ${webhookResponse.data}`,
details: JSON.stringify(webhookResponse.data, null, 2).substring(0, 1000),
})
result &&
(await saveSuccessLog({
resultId: result.id,
message: `Webhook returned success: ${webhookResponse.data}`,
details: JSON.stringify(webhookResponse.data, null, 2).substring(
0,
1000
),
}))
}
const newVariables = block.options.responseVariableMapping.reduce<
@ -265,6 +274,7 @@ const convertKeyValueTableToObject = (
}, {})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const safeJsonParse = (json: string): { data: any; isJson: boolean } => {
try {
return { data: JSON.parse(json), isJson: true }

View File

@ -20,29 +20,33 @@ test.describe('Bot', () => {
publicId: `${typebotId}-public`,
})
await createWebhook(typebotId, {
id: 'failing-webhook',
url: 'http://localhost:3001/api/mock/fail',
method: HttpMethod.POST,
})
try {
await createWebhook(typebotId, {
id: 'failing-webhook',
url: 'http://localhost:3001/api/mock/fail',
method: HttpMethod.POST,
})
await createWebhook(typebotId, {
id: 'partial-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{
await createWebhook(typebotId, {
id: 'partial-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{
"name": "{{Name}}",
"age": {{Age}},
"gender": "{{Gender}}"
}`,
})
})
await createWebhook(typebotId, {
id: 'full-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{{Full body}}`,
})
await createWebhook(typebotId, {
id: 'full-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{{Full body}}`,
})
} catch (err) {
console.log(err)
}
})
test.afterEach(async () => {

View File

@ -0,0 +1,78 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { HttpMethod } from 'models'
import {
createWebhook,
deleteTypebots,
deleteWebhooks,
importTypebotInDatabase,
} from 'utils/playwright/databaseActions'
import { getTestAsset } from '@/test/utils/playwright'
const typebotId = cuid()
test.beforeEach(async () => {
await importTypebotInDatabase(getTestAsset('typebots/webhook.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
try {
await createWebhook(typebotId, {
id: 'failing-webhook',
url: 'http://localhost:3001/api/mock/fail',
method: HttpMethod.POST,
})
await createWebhook(typebotId, {
id: 'partial-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{
"name": "{{Name}}",
"age": {{Age}},
"gender": "{{Gender}}"
}`,
})
await createWebhook(typebotId, {
id: 'full-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{{Full body}}`,
})
} catch (err) {
console.log(err)
}
})
test.afterEach(async () => {
await deleteTypebots([typebotId])
await deleteWebhooks([
'failing-webhook',
'partial-body-webhook',
'full-body-webhook',
])
})
test('should execute webhooks properly', async ({ page }) => {
await page.goto(`/next/${typebotId}-public`)
await page.locator('text=Send failing webhook').click()
await page.locator('[placeholder="Type a name..."]').fill('John')
await page.locator('text="Send"').click()
await page.locator('[placeholder="Type an age..."]').fill('30')
await page.locator('text="Send"').click()
await page.locator('text="Male"').click()
await expect(
page.getByText('{"name":"John","age":25,"gender":"male"}')
).toBeVisible()
await expect(
page.getByText('{"name":"John","age":30,"gender":"Male"}')
).toBeVisible()
await page.goto(`http://localhost:3000/typebots/${typebotId}/results`)
await page.click('text="See logs"')
await expect(
page.locator('text="Webhook successfuly executed." >> nth=1')
).toBeVisible()
await expect(page.locator('text="Webhook returned an error"')).toBeVisible()
})

View File

@ -10,7 +10,9 @@ export const executeRedirect = (
if (!block.options?.url) return { outgoingEdgeId: block.outgoingEdgeId }
const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url))
return {
logic: { redirectUrl: formattedUrl },
logic: {
redirect: { url: formattedUrl, isNewTab: block.options.isNewTab },
},
outgoingEdgeId: block.outgoingEdgeId,
}
}

View File

@ -1,7 +1,13 @@
import { ExecuteLogicResponse } from '@/features/chat'
import { saveErrorLog } from '@/features/logs/api'
import prisma from '@/lib/prisma'
import { TypebotLinkBlock, Edge, SessionState, TypebotInSession } from 'models'
import {
TypebotLinkBlock,
Edge,
SessionState,
TypebotInSession,
Variable,
} from 'models'
import { byId } from 'utils'
export const executeTypebotLink = async (
@ -9,20 +15,22 @@ export const executeTypebotLink = async (
block: TypebotLinkBlock
): Promise<ExecuteLogicResponse> => {
if (!block.options.typebotId) {
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: 'Typebot ID is not specified',
})
state.result &&
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: 'Typebot ID is not specified',
})
return { outgoingEdgeId: block.outgoingEdgeId }
}
const linkedTypebot = await getLinkedTypebot(state, block.options.typebotId)
if (!linkedTypebot) {
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: `Typebot with ID ${block.options.typebotId} not found`,
})
state.result &&
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: `Typebot with ID ${block.options.typebotId} not found`,
})
return { outgoingEdgeId: block.outgoingEdgeId }
}
let newSessionState = addLinkedTypebotToState(state, block, linkedTypebot)
@ -32,11 +40,12 @@ export const executeTypebotLink = async (
linkedTypebot.groups.find((b) => b.blocks.some((s) => s.type === 'start'))
?.id
if (!nextGroupId) {
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: `Group with ID "${block.options.groupId}" not found`,
})
state.result &&
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: `Group with ID "${block.options.groupId}" not found`,
})
return { outgoingEdgeId: block.outgoingEdgeId }
}
const portalEdge: Edge = {
@ -65,29 +74,50 @@ const addLinkedTypebotToState = (
state: SessionState,
block: TypebotLinkBlock,
linkedTypebot: TypebotInSession
): SessionState => ({
...state,
typebot: {
...state.typebot,
groups: [...state.typebot.groups, ...linkedTypebot.groups],
variables: [...state.typebot.variables, ...linkedTypebot.variables],
edges: [...state.typebot.edges, ...linkedTypebot.edges],
},
linkedTypebots: {
typebots: [
...state.linkedTypebots.typebots.filter(
(existingTypebots) => existingTypebots.id !== linkedTypebot.id
),
],
queue: block.outgoingEdgeId
? [
...state.linkedTypebots.queue,
{ edgeId: block.outgoingEdgeId, typebotId: state.currentTypebotId },
]
: state.linkedTypebots.queue,
},
currentTypebotId: linkedTypebot.id,
})
): SessionState => {
const incomingVariables = fillVariablesWithExistingValues(
linkedTypebot.variables,
state.typebot.variables
)
return {
...state,
typebot: {
...state.typebot,
groups: [...state.typebot.groups, ...linkedTypebot.groups],
variables: [...state.typebot.variables, ...incomingVariables],
edges: [...state.typebot.edges, ...linkedTypebot.edges],
},
linkedTypebots: {
typebots: [
...state.linkedTypebots.typebots.filter(
(existingTypebots) => existingTypebots.id !== linkedTypebot.id
),
],
queue: block.outgoingEdgeId
? [
...state.linkedTypebots.queue,
{ edgeId: block.outgoingEdgeId, typebotId: state.currentTypebotId },
]
: state.linkedTypebots.queue,
},
currentTypebotId: linkedTypebot.id,
}
}
const fillVariablesWithExistingValues = (
variables: Variable[],
variablesWithValues: Variable[]
): Variable[] =>
variables.map((variable) => {
const matchedVariable = variablesWithValues.find(
(variableWithValue) => variableWithValue.name === variable.name
)
return {
...variable,
value: matchedVariable?.value ?? variable.value,
}
})
const getLinkedTypebot = async (
state: SessionState,

View File

@ -3,7 +3,7 @@ import test, { expect } from '@playwright/test'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
const typebotId = 'cl0ibhi7s0018n21aarlmg0cm'
const typebotId = 'cl0ibhi7s0018n21aarlmg0cm1'
const linkedTypebotId = 'cl0ibhv8d0130n21aw8doxhj5'
test.beforeAll(async () => {

View File

@ -0,0 +1,30 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
const typebotId = 'cl0ibhi7s0018n21aarlmg0cm'
const linkedTypebotId = 'cl0ibhv8d0130n21aw8doxhj5'
test.beforeAll(async () => {
try {
await importTypebotInDatabase(
getTestAsset('typebots/linkTypebots/1.json'),
{ id: typebotId, publicId: `${typebotId}-public` }
)
await importTypebotInDatabase(
getTestAsset('typebots/linkTypebots/2.json'),
{ id: linkedTypebotId, publicId: `${linkedTypebotId}-public` }
)
} catch (err) {
console.error(err)
}
})
test('should work as expected', async ({ page }) => {
await page.goto(`/next/${typebotId}-public`)
await page.locator('input').fill('Hello there!')
await page.locator('input').press('Enter')
await expect(page.getByText('Cheers!')).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text=Hello there!')).toBeVisible()
})

View File

@ -1,58 +1,40 @@
import { checkChatsUsage } from '@/features/usage'
import { parsePrefilledVariables } from '@/features/variables'
import prisma from '@/lib/prisma'
import { publicProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Prisma } from 'db'
import {
chatReplySchema,
ChatSession,
PublicTypebotWithName,
PublicTypebot,
Result,
sendMessageInputSchema,
SessionState,
typebotSchema,
StartParams,
Typebot,
Variable,
} from 'models'
import { z } from 'zod'
import { continueBotFlow, getSession, startBotFlow } from '../utils'
export const sendMessageProcedure = publicProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/sendMessage',
path: '/sendMessage',
summary: 'Send a message',
description:
"To initiate a chat, don't provide a `sessionId` and enter any `message`.\n\nContinue the conversation by providing the `sessionId` and the `message` that should answer the previous question.\n\nSet the `isPreview` option to `true` to chat with the non-published version of the typebot.",
'To initiate a chat, do not provide a `sessionId` nor a `message`.\n\nContinue the conversation by providing the `sessionId` and the `message` that should answer the previous question.\n\nSet the `isPreview` option to `true` to chat with the non-published version of the typebot.',
},
})
.input(
z.object({
typebotId: z.string({
description:
'[How can I find my typebot ID?](https://docs.typebot.io/api#how-to-find-my-typebotid)',
}),
message: z.string().describe('The answer to the previous question'),
sessionId: z
.string()
.optional()
.describe(
'Session ID that you get from the initial chat request to a bot'
),
isPreview: z.boolean().optional(),
})
)
.output(
chatReplySchema.and(
z.object({
sessionId: z.string().nullish(),
typebot: typebotSchema.pick({ theme: true, settings: true }).nullish(),
})
)
)
.query(async ({ input: { typebotId, sessionId, message } }) => {
.input(sendMessageInputSchema)
.output(chatReplySchema)
.query(async ({ input: { sessionId, message, startParams } }) => {
const session = sessionId ? await getSession(sessionId) : null
if (!session) {
const { sessionId, typebot, messages, input } = await startSession(
typebotId
)
const { sessionId, typebot, messages, input, resultId } =
await startSession(startParams)
return {
sessionId,
typebot: typebot
@ -60,14 +42,14 @@ export const sendMessageProcedure = publicProcedure
theme: typebot.theme,
settings: typebot.settings,
}
: null,
: undefined,
messages,
input,
resultId,
}
} else {
const { messages, input, logic, newSessionState } = await continueBotFlow(
session.state
)(message)
const { messages, input, logic, newSessionState, integrations } =
await continueBotFlow(session.state)(message)
await prisma.chatSession.updateMany({
where: { id: session.id },
@ -80,59 +62,103 @@ export const sendMessageProcedure = publicProcedure
messages,
input,
logic,
integrations,
}
}
})
const startSession = async (typebotId: string) => {
const typebot = await prisma.typebot.findUnique({
where: { id: typebotId },
select: {
publishedTypebot: true,
name: true,
isClosed: true,
isArchived: true,
id: true,
},
})
const startSession = async (startParams?: StartParams) => {
if (!startParams?.typebotId)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No typebotId provided in startParams',
})
const typebotQuery = startParams.isPreview
? await prisma.typebot.findUnique({
where: { id: startParams.typebotId },
select: {
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
isArchived: true,
},
})
: await prisma.typebot.findUnique({
where: { id: startParams.typebotId },
select: {
publishedTypebot: {
select: {
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
},
},
name: true,
isClosed: true,
isArchived: true,
id: true,
},
})
if (!typebot?.publishedTypebot || typebot.isArchived)
const typebot =
typebotQuery && 'publishedTypebot' in typebotQuery
? (typebotQuery.publishedTypebot as Pick<
PublicTypebot,
'groups' | 'edges' | 'settings' | 'theme' | 'variables'
>)
: (typebotQuery as Pick<
Typebot,
'groups' | 'edges' | 'settings' | 'theme' | 'variables' | 'isArchived'
>)
if (!typebot)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
if (typebot.isClosed)
if ('isClosed' in typebot && typebot.isClosed)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Typebot is closed',
})
const result = (await prisma.result.create({
data: { isCompleted: false, typebotId },
select: {
id: true,
variables: true,
hasStarted: true,
},
})) as Pick<Result, 'id' | 'variables' | 'hasStarted'>
const hasReachedLimit = !startParams.isPreview
? await checkChatsUsage(startParams.typebotId)
: false
const publicTypebot = typebot.publishedTypebot as PublicTypebotWithName
if (hasReachedLimit)
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Your workspace reached its chat limit',
})
const startVariables = startParams.prefilledVariables
? parsePrefilledVariables(typebot.variables, startParams.prefilledVariables)
: typebot.variables
const result = await getResult({ ...startParams, startVariables })
const initialState: SessionState = {
typebot: {
id: publicTypebot.typebotId,
groups: publicTypebot.groups,
edges: publicTypebot.edges,
variables: publicTypebot.variables,
id: startParams.typebotId,
groups: typebot.groups,
edges: typebot.edges,
variables: startVariables,
},
linkedTypebots: {
typebots: [],
queue: [],
},
result: { id: result.id, variables: [], hasStarted: false },
result: result
? { id: result.id, variables: result.variables, hasStarted: false }
: undefined,
isPreview: false,
currentTypebotId: publicTypebot.typebotId,
currentTypebotId: startParams.typebotId,
}
const {
@ -145,8 +171,6 @@ const startSession = async (typebotId: string) => {
if (!input)
return {
messages,
typebot: null,
sessionId: null,
logic,
}
@ -165,13 +189,47 @@ const startSession = async (typebotId: string) => {
})) as ChatSession
return {
resultId: result?.id,
sessionId: session.id,
typebot: {
theme: publicTypebot.theme,
settings: publicTypebot.settings,
theme: typebot.theme,
settings: typebot.settings,
},
messages,
input,
logic,
}
}
const getResult = async ({
typebotId,
isPreview,
resultId,
startVariables,
}: Pick<StartParams, 'isPreview' | 'resultId' | 'typebotId'> & {
startVariables: Variable[]
}) => {
if (isPreview) return undefined
const data = {
isCompleted: false,
typebotId: typebotId,
variables: { set: startVariables.filter((variable) => variable.value) },
} satisfies Prisma.ResultUncheckedCreateInput
const select = {
id: true,
variables: true,
hasStarted: true,
} satisfies Prisma.ResultSelect
return (
resultId
? await prisma.result.update({
where: { id: resultId },
data,
select,
})
: await prisma.result.create({
data,
select,
})
) as Pick<Result, 'id' | 'variables' | 'hasStarted'>
}

View File

@ -1,11 +1,16 @@
import { validateButtonInput } from '@/features/blocks/inputs/buttons/api'
import { validateEmail } from '@/features/blocks/inputs/email/api'
import { validatePhoneNumber } from '@/features/blocks/inputs/phone/api'
import {
formatPhoneNumber,
validatePhoneNumber,
} from '@/features/blocks/inputs/phone/api'
import { validateUrl } from '@/features/blocks/inputs/url/api'
import { parseVariables } from '@/features/variables'
import prisma from '@/lib/prisma'
import { TRPCError } from '@trpc/server'
import {
Block,
BlockType,
BubbleBlockType,
ChatReply,
InputBlock,
@ -20,7 +25,7 @@ import { getNextGroup } from './getNextGroup'
export const continueBotFlow =
(state: SessionState) =>
async (
reply: string
reply?: string
): Promise<ChatReply & { newSessionState?: SessionState }> => {
const group = state.typebot.groups.find(
(group) => group.id === state.currentBlock?.groupId
@ -30,7 +35,7 @@ export const continueBotFlow =
(block) => block.id === state.currentBlock?.blockId
) ?? -1
const block = blockIndex > 0 ? group?.blocks[blockIndex ?? 0] : null
const block = blockIndex >= 0 ? group?.blocks[blockIndex ?? 0] : null
if (!block || !group)
throw new TRPCError({
@ -44,9 +49,15 @@ export const continueBotFlow =
message: 'Current block is not an input block',
})
if (!isInputValid(reply, block)) return parseRetryMessage(block)
const formattedReply = formatReply(reply, block.type)
const newVariables = await processAndSaveAnswer(state, block)(reply)
if (!formattedReply || !isReplyValid(formattedReply, block))
return parseRetryMessage(block)
const newVariables = await processAndSaveAnswer(
state,
block
)(formattedReply)
const newSessionState = {
...state,
@ -58,15 +69,15 @@ export const continueBotFlow =
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
if (groupHasMoreBlocks) {
const nextEdgeId = getOutgoingEdgeId(newSessionState)(block, formattedReply)
if (groupHasMoreBlocks && !nextEdgeId) {
return executeGroup(newSessionState)({
...group,
blocks: group.blocks.slice(blockIndex + 1),
})
}
const nextEdgeId = block.outgoingEdgeId
if (!nextEdgeId && state.linkedTypebots.queue.length === 0)
return { messages: [] }
@ -80,7 +91,7 @@ export const continueBotFlow =
const processAndSaveAnswer =
(state: Pick<SessionState, 'result' | 'typebot'>, block: InputBlock) =>
async (reply: string): Promise<Variable[]> => {
await saveAnswer(state.result.id, block)(reply)
state.result && (await saveAnswer(state.result.id, block)(reply))
const newVariables = saveVariableValueIfAny(state, block)(reply)
return newVariables
}
@ -105,22 +116,26 @@ const saveVariableValueIfAny =
]
}
const parseRetryMessage = (block: InputBlock) => ({
messages: [
{
type: BubbleBlockType.TEXT,
content: {
plainText:
'retryMessageContent' in block.options
? block.options.retryMessageContent
: 'Invalid message. Please, try again.',
richText: [],
html: '',
const parseRetryMessage = (
block: InputBlock
): Pick<ChatReply, 'messages' | 'input'> => {
const retryMessage =
'retryMessageContent' in block.options && block.options.retryMessageContent
? block.options.retryMessageContent
: 'Invalid message. Please, try again.'
return {
messages: [
{
type: BubbleBlockType.TEXT,
content: {
plainText: retryMessage,
html: `<div>${retryMessage}</div>`,
},
},
},
],
input: block,
})
],
input: block,
}
}
const saveAnswer =
(resultId: string, block: InputBlock) => async (reply: string) => {
@ -135,7 +150,35 @@ const saveAnswer =
})
}
export const isInputValid = (inputValue: string, block: Block): boolean => {
const getOutgoingEdgeId =
({ typebot: { variables } }: Pick<SessionState, 'typebot'>) =>
(block: InputBlock, reply?: string) => {
if (
block.type === InputBlockType.CHOICE &&
!block.options.isMultipleChoice &&
reply
) {
const matchedItem = block.items.find(
(item) => parseVariables(variables)(item.content) === reply
)
if (matchedItem?.outgoingEdgeId) return matchedItem.outgoingEdgeId
}
return block.outgoingEdgeId
}
export const formatReply = (
inputValue: string | undefined,
blockType: BlockType
): string | null => {
if (!inputValue) return null
switch (blockType) {
case InputBlockType.PHONE:
return formatPhoneNumber(inputValue)
}
return inputValue
}
export const isReplyValid = (inputValue: string, block: Block): boolean => {
switch (block.type) {
case InputBlockType.EMAIL:
return validateEmail(inputValue)

View File

@ -2,13 +2,17 @@ import { parseVariables } from '@/features/variables'
import {
BubbleBlock,
BubbleBlockType,
ChatMessageContent,
ChatMessage,
ChatReply,
Group,
InputBlock,
InputBlockType,
RuntimeOptions,
SessionState,
} from 'models'
import {
isBubbleBlock,
isDefined,
isInputBlock,
isIntegrationBlock,
isLogicBlock,
@ -16,6 +20,7 @@ import {
import { executeLogic } from './executeLogic'
import { getNextGroup } from './getNextGroup'
import { executeIntegration } from './executeIntegration'
import { computePaymentInputRuntimeOptions } from '@/features/blocks/inputs/payment/api'
export const executeGroup =
(state: SessionState, currentReply?: ChatReply) =>
@ -33,17 +38,20 @@ export const executeGroup =
nextEdgeId = block.outgoingEdgeId
if (isBubbleBlock(block)) {
messages.push({
type: block.type,
content: parseBubbleBlockContent(newSessionState)(block),
})
messages.push(parseBubbleBlockContent(newSessionState)(block))
continue
}
if (isInputBlock(block))
return {
messages,
input: block,
input: {
...block,
runtimeOptions: await computeRuntimeOptions(newSessionState)(block),
prefilledValue: getPrefilledInputValue(
newSessionState.typebot.variables
)(block),
},
newSessionState: {
...newSessionState,
currentBlock: {
@ -53,9 +61,9 @@ export const executeGroup =
},
}
const executionResponse = isLogicBlock(block)
? await executeLogic(state)(block)
? await executeLogic(newSessionState)(block)
: isIntegrationBlock(block)
? await executeIntegration(state)(block)
? await executeIntegration(newSessionState)(block)
: null
if (!executionResponse) continue
@ -84,30 +92,50 @@ export const executeGroup =
)
}
const computeRuntimeOptions =
(state: SessionState) =>
(block: InputBlock): Promise<RuntimeOptions> | undefined => {
switch (block.type) {
case InputBlockType.PAYMENT: {
return computePaymentInputRuntimeOptions(state)(block.options)
}
}
}
const parseBubbleBlockContent =
({ typebot: { variables } }: SessionState) =>
(block: BubbleBlock): ChatMessageContent => {
(block: BubbleBlock): ChatMessage => {
switch (block.type) {
case BubbleBlockType.TEXT: {
const plainText = parseVariables(variables)(block.content.plainText)
const html = parseVariables(variables)(block.content.html)
return { plainText, html }
return { type: block.type, content: { plainText, html } }
}
case BubbleBlockType.IMAGE: {
const url = parseVariables(variables)(block.content.url)
return { url }
return { type: block.type, content: { ...block.content, url } }
}
case BubbleBlockType.VIDEO: {
const url = parseVariables(variables)(block.content.url)
return { url }
return { type: block.type, content: { ...block.content, url } }
}
case BubbleBlockType.AUDIO: {
const url = parseVariables(variables)(block.content.url)
return { url }
return { type: block.type, content: { ...block.content, url } }
}
case BubbleBlockType.EMBED: {
const url = parseVariables(variables)(block.content.url)
return { url }
return { type: block.type, content: { ...block.content, url } }
}
}
}
const getPrefilledInputValue =
(variables: SessionState['typebot']['variables']) => (block: InputBlock) => {
return (
variables.find(
(variable) =>
variable.id === block.options.variableId && isDefined(variable.value)
)?.value ?? undefined
)
}

View File

@ -1,7 +1,8 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { HttpMethod } from 'models'
import prisma from '@/lib/prisma'
import { HttpMethod, SendMessageInput } from 'models'
import {
createWebhook,
deleteTypebots,
@ -9,10 +10,14 @@ import {
importTypebotInDatabase,
} from 'utils/playwright/databaseActions'
const typebotId = cuid()
const publicId = `${typebotId}-public`
test.afterEach(async () => {
await deleteWebhooks(['chat-webhook-id'])
await deleteTypebots(['chat-sub-bot'])
})
test.beforeEach(async () => {
test('API chat execution should work on preview bot', async ({ request }) => {
const typebotId = cuid()
const publicId = `${typebotId}-public`
await importTypebotInDatabase(getTestAsset('typebots/chat/main.json'), {
id: typebotId,
publicId,
@ -26,25 +31,64 @@ test.beforeEach(async () => {
method: HttpMethod.GET,
url: 'https://api.chucknorris.io/jokes/random',
})
await test.step('Start the chat', async () => {
const { sessionId, messages, input, resultId } = await (
await request.post(`/api/v1/sendMessage`, {
data: {
startParams: {
typebotId,
isPreview: true,
},
// TODO: replace with satisfies once compatible with playwright
} as SendMessageInput,
})
).json()
expect(resultId).toBeUndefined()
expect(sessionId).toBeDefined()
expect(messages[0].content.plainText).toBe('Hi there! 👋')
expect(messages[1].content.plainText).toBe("Welcome. What's your name?")
expect(input.type).toBe('text input')
})
})
test.afterEach(async () => {
await deleteWebhooks(['chat-webhook-id'])
await deleteTypebots(['chat-sub-bot'])
})
test('API chat execution should work', async ({ request }) => {
test('API chat execution should work on published bot', async ({ request }) => {
const typebotId = cuid()
const publicId = `${typebotId}-public`
await importTypebotInDatabase(getTestAsset('typebots/chat/main.json'), {
id: typebotId,
publicId,
})
await importTypebotInDatabase(getTestAsset('typebots/chat/linkedBot.json'), {
id: 'chat-sub-bot',
publicId: 'chat-sub-bot-public',
})
await createWebhook(typebotId, {
id: 'chat-webhook-id',
method: HttpMethod.GET,
url: 'https://api.chucknorris.io/jokes/random',
})
let chatSessionId: string
await test.step('Start the chat', async () => {
const { sessionId, messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
const { sessionId, messages, input, resultId } = await (
await request.post(`/api/v1/sendMessage`, {
data: {
message: 'Hi',
},
startParams: {
typebotId,
},
// TODO: replace with satisfies once compatible with playwright
} as SendMessageInput,
})
).json()
chatSessionId = sessionId
expect(resultId).toBeDefined()
const result = await prisma.result.findUnique({
where: {
id: resultId,
},
})
expect(result).toBeDefined()
expect(sessionId).toBeDefined()
expect(messages[0].content.plainText).toBe('Hi there! 👋')
expect(messages[1].content.plainText).toBe("Welcome. What's your name?")
@ -53,7 +97,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Name question', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: 'John', sessionId: chatSessionId },
})
).json()
@ -64,7 +108,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Age question', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: '24', sessionId: chatSessionId },
})
).json()
@ -78,7 +122,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Rating question', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: '8', sessionId: chatSessionId },
})
).json()
@ -90,7 +134,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Email question with wrong input', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: 'invalid email', sessionId: chatSessionId },
})
).json()
@ -102,7 +146,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Email question with valid input', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: 'typebot@email.com', sessionId: chatSessionId },
})
).json()
@ -112,7 +156,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer URL question', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: 'https://typebot.io', sessionId: chatSessionId },
})
).json()
@ -122,7 +166,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Buttons question with invalid choice', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: 'Yolo', sessionId: chatSessionId },
})
).json()
@ -134,7 +178,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Buttons question with invalid choice', async () => {
const { messages } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: 'Yes', sessionId: chatSessionId },
})
).json()

View File

@ -0,0 +1,27 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
test('Big groups should work as expected', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/hugeGroup.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await page.goto(`/next/${typebotId}-public`)
await page.locator('input').fill('Baptiste')
await page.locator('input').press('Enter')
await page.locator('input').fill('26')
await page.locator('input').press('Enter')
await page.locator('button >> text=Yes').click()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text="Baptiste"')).toBeVisible()
await expect(page.locator('text="26"')).toBeVisible()
await expect(page.locator('text="Yes"')).toBeVisible()
await page.hover('tbody > tr')
await page.click('button >> text="Open"')
await expect(page.locator('text="Baptiste" >> nth=1')).toBeVisible()
await expect(page.locator('text="26" >> nth=1')).toBeVisible()
await expect(page.locator('text="Yes" >> nth=1')).toBeVisible()
})

View File

@ -0,0 +1,179 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import {
defaultSettings,
defaultTextInputOptions,
InputBlockType,
Metadata,
} from 'models'
import { createTypebots, updateTypebot } from 'utils/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
test('Result should be overwritten on page refresh', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
const [, response] = await Promise.all([
page.goto(`/next/${typebotId}-public`),
page.waitForResponse(/sendMessage/),
])
const { resultId } = await response.json()
expect(resultId).toBeDefined()
await expect(page.getByRole('textbox')).toBeVisible()
const [, secondResponse] = await Promise.all([
page.reload(),
page.waitForResponse(/sendMessage/),
])
const { resultId: secondResultId } = await secondResponse.json()
expect(secondResultId).toBe(resultId)
})
test.describe('Create result on page refresh enabled', () => {
test('should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
settings: {
...defaultSettings,
general: {
...defaultSettings.general,
isNewResultOnRefreshEnabled: true,
},
},
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
const [, response] = await Promise.all([
page.goto(`/next/${typebotId}-public`),
page.waitForResponse(/sendMessage/),
])
const { resultId } = await response.json()
expect(resultId).toBeDefined()
await expect(page.getByRole('textbox')).toBeVisible()
const [, secondResponse] = await Promise.all([
page.reload(),
page.waitForResponse(/sendMessage/),
])
const { resultId: secondResultId } = await secondResponse.json()
expect(secondResultId).not.toBe(resultId)
})
})
test('Hide query params', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await page.goto(`/next/${typebotId}-public?Name=John`)
await page.waitForTimeout(1000)
expect(page.url()).toEqual(`http://localhost:3001/next/${typebotId}-public`)
await updateTypebot({
id: typebotId,
settings: {
...defaultSettings,
general: { ...defaultSettings.general, isHideQueryParamsEnabled: false },
},
})
await page.goto(`/next/${typebotId}-public?Name=John`)
await page.waitForTimeout(1000)
expect(page.url()).toEqual(
`http://localhost:3001/next/${typebotId}-public?Name=John`
)
})
test('Show close message', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
isClosed: true,
},
])
await page.goto(`/next/${typebotId}-public`)
await expect(page.locator('text=This bot is now closed')).toBeVisible()
})
test('Should correctly parse metadata', async ({ page }) => {
const typebotId = cuid()
const customMetadata: Metadata = {
description: 'My custom description',
title: 'Custom title',
favIconUrl: 'https://www.baptistearno.com/favicon.png',
imageUrl: 'https://www.baptistearno.com/images/site-preview.png',
customHeadCode: '<meta name="author" content="John Doe">',
}
await createTypebots([
{
id: typebotId,
settings: {
...defaultSettings,
metadata: customMetadata,
},
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await page.goto(`/next/${typebotId}-public`)
expect(
await page.evaluate(`document.querySelector('title').textContent`)
).toBe(customMetadata.title)
expect(
await page.evaluate(
() =>
(document.querySelector('meta[name="description"]') as HTMLMetaElement)
.content
)
).toBe(customMetadata.description)
expect(
await page.evaluate(
() =>
(document.querySelector('meta[property="og:image"]') as HTMLMetaElement)
.content
)
).toBe(customMetadata.imageUrl)
expect(
await page.evaluate(() =>
(
document.querySelector('link[rel="icon"]') as HTMLLinkElement
).getAttribute('href')
)
).toBe(customMetadata.favIconUrl)
await expect(
page.locator(
`input[placeholder="${defaultTextInputOptions.labels.placeholder}"]`
)
).toBeVisible()
expect(
await page.evaluate(
() =>
(document.querySelector('meta[name="author"]') as HTMLMetaElement)
.content
)
).toBe('John Doe')
})

View File

@ -0,0 +1 @@
export * from './utils'

View File

@ -0,0 +1,70 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { Plan } from 'db'
import { defaultSettings } from 'models'
import {
createWorkspaces,
importTypebotInDatabase,
injectFakeResults,
} from 'utils/playwright/databaseActions'
test('should not start if chat limit is reached', async ({ page, context }) => {
await test.step('Free plan', async () => {
const workspaceId = cuid()
const typebotId = cuid()
await createWorkspaces([{ id: workspaceId, plan: Plan.FREE }])
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
workspaceId,
})
await injectFakeResults({ typebotId, count: 400 })
await page.goto(`/next/${typebotId}-public`)
await expect(page.locator('text="This bot is now closed."')).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text="133%"')).toBeVisible()
})
await test.step('Lifetime plan', async () => {
const workspaceId = cuid()
const typebotId = cuid()
await createWorkspaces([{ id: workspaceId, plan: Plan.LIFETIME }])
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
workspaceId,
})
await injectFakeResults({ typebotId, count: 3000 })
await page.goto(`/next/${typebotId}-public`)
await expect(page.locator('text="Hey there, upload please"')).toBeVisible()
})
await test.step('Custom plan', async () => {
const workspaceId = cuid()
const typebotId = cuid()
await createWorkspaces([
{ id: workspaceId, plan: Plan.CUSTOM, customChatsLimit: 1000 },
])
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
workspaceId,
settings: {
...defaultSettings,
general: {
...defaultSettings.general,
isNewResultOnRefreshEnabled: true,
},
},
})
const page = await context.newPage()
await page.goto(`/next/${typebotId}-public`)
await expect(page.locator('text="Hey there, upload please"')).toBeVisible()
await injectFakeResults({ typebotId, count: 2000 })
await page.goto(`/next/${typebotId}-public`)
await expect(page.locator('text="This bot is now closed."')).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text="200%"')).toBeVisible()
})
})

View File

@ -0,0 +1,130 @@
import prisma from '@/lib/prisma'
import { WorkspaceRole } from 'db'
import {
sendAlmostReachedChatsLimitEmail,
sendReachedChatsLimitEmail,
} from 'emails'
import { env, getChatsLimit, isDefined } from 'utils'
const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8
export const checkChatsUsage = async (typebotId: string) => {
const typebot = await prisma.typebot.findUnique({
where: {
id: typebotId,
},
include: {
workspace: {
select: {
id: true,
plan: true,
additionalChatsIndex: true,
chatsLimitFirstEmailSentAt: true,
chatsLimitSecondEmailSentAt: true,
customChatsLimit: true,
},
},
},
})
const workspace = typebot?.workspace
if (!workspace) return false
const chatsLimit = getChatsLimit(workspace)
if (chatsLimit === -1) return
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const firstDayOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1)
const chatsCount = await prisma.$transaction(async (tx) => {
const typebotIds = await tx.typebot.findMany({
where: {
workspaceId: workspace.id,
},
select: { id: true },
})
return tx.result.count({
where: {
typebotId: { in: typebotIds.map((typebot) => typebot.id) },
hasStarted: true,
createdAt: { gte: firstDayOfMonth, lte: firstDayOfNextMonth },
},
})
})
const hasSentFirstEmail =
workspace.chatsLimitFirstEmailSentAt !== null &&
workspace.chatsLimitFirstEmailSentAt < firstDayOfNextMonth &&
workspace.chatsLimitFirstEmailSentAt > firstDayOfMonth
const hasSentSecondEmail =
workspace.chatsLimitSecondEmailSentAt !== null &&
workspace.chatsLimitSecondEmailSentAt < firstDayOfNextMonth &&
workspace.chatsLimitSecondEmailSentAt > firstDayOfMonth
if (
chatsCount >= chatsLimit * LIMIT_EMAIL_TRIGGER_PERCENT &&
!hasSentFirstEmail &&
env('E2E_TEST') !== 'true'
)
await sendAlmostReachChatsLimitNotification({
workspaceId: workspace.id,
chatsLimit,
})
if (
chatsCount >= chatsLimit &&
!hasSentSecondEmail &&
env('E2E_TEST') !== 'true'
)
await sendReachedAlertNotification({
workspaceId: workspace.id,
chatsLimit,
})
return chatsCount >= chatsLimit
}
const sendAlmostReachChatsLimitNotification = async ({
workspaceId,
chatsLimit,
}: {
workspaceId: string
chatsLimit: number
}) => {
const members = await prisma.memberInWorkspace.findMany({
where: { role: WorkspaceRole.ADMIN, workspaceId },
include: { user: { select: { email: true } } },
})
await sendAlmostReachedChatsLimitEmail({
to: members.map((member) => member.user.email).filter(isDefined),
chatsLimit,
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
})
await prisma.workspace.update({
where: { id: workspaceId },
data: { chatsLimitFirstEmailSentAt: new Date() },
})
}
const sendReachedAlertNotification = async ({
workspaceId,
chatsLimit,
}: {
workspaceId: string
chatsLimit: number
}) => {
const members = await prisma.memberInWorkspace.findMany({
where: { role: WorkspaceRole.ADMIN, workspaceId },
include: { user: { select: { email: true } } },
})
await sendReachedChatsLimitEmail({
to: members.map((member) => member.user.email).filter(isDefined),
chatsLimit,
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
})
await prisma.workspace.update({
where: { id: workspaceId },
data: { chatsLimitSecondEmailSentAt: new Date() },
})
}

View File

@ -0,0 +1 @@
export * from './checkChatsUsage'

View File

@ -1,6 +1,8 @@
import prisma from '@/lib/prisma'
import {
SessionState,
StartParams,
Typebot,
Variable,
VariableWithUnknowValue,
VariableWithValue,
@ -99,6 +101,19 @@ export const parseVariablesInObject = (
}
}, {})
export const parsePrefilledVariables = (
variables: Typebot['variables'],
prefilledVariables: NonNullable<StartParams['prefilledVariables']>
): Variable[] =>
variables.map((variable) => {
const prefilledVariable = prefilledVariables[variable.name]
if (!prefilledVariable) return variable
return {
...variable,
value: safeStringify(prefilledVariable),
}
})
export const updateVariables =
(state: SessionState) =>
async (newVariables: VariableWithUnknowValue[]): Promise<SessionState> => ({
@ -107,10 +122,12 @@ export const updateVariables =
...state.typebot,
variables: updateTypebotVariables(state)(newVariables),
},
result: {
...state.result,
variables: await updateResultVariables(state)(newVariables),
},
result: state.result
? {
...state.result,
variables: await updateResultVariables(state)(newVariables),
}
: undefined,
})
const updateResultVariables =
@ -118,6 +135,7 @@ const updateResultVariables =
async (
newVariables: VariableWithUnknowValue[]
): Promise<VariableWithValue[]> => {
if (!result) return []
const serializedNewVariables = newVariables.map((variable) => ({
...variable,
value: safeStringify(variable.value),

View File

@ -0,0 +1,21 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
test('should correctly be injected', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
getTestAsset('typebots/predefinedVariables.json'),
{ id: typebotId, publicId: `${typebotId}-public` }
)
await page.goto(`/next/${typebotId}-public`)
await expect(page.locator('text="Your name is"')).toBeVisible()
await page.goto(
`/next/${typebotId}-public?Name=Baptiste&Email=email@test.com`
)
await expect(page.locator('text="Your name is Baptiste"')).toBeVisible()
await expect(page.getByPlaceholder('Type your email...')).toHaveValue(
'email@test.com'
)
})

View File

@ -1,17 +1,10 @@
import { authenticateUser } from '@/features/auth/api'
import { checkChatsUsage } from '@/features/usage'
import prisma from '@/lib/prisma'
import { WorkspaceRole } from 'db'
import {
sendAlmostReachedChatsLimitEmail,
sendReachedChatsLimitEmail,
} from 'emails'
import { ResultWithAnswers } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { env, getChatsLimit, isDefined } from 'utils'
import { methodNotAllowed } from 'utils/api'
const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') {
const user = await authenticateUser(req)
@ -47,125 +40,4 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
methodNotAllowed(res)
}
const checkChatsUsage = async (typebotId: string) => {
const typebot = await prisma.typebot.findUnique({
where: {
id: typebotId,
},
include: {
workspace: {
select: {
id: true,
plan: true,
additionalChatsIndex: true,
chatsLimitFirstEmailSentAt: true,
chatsLimitSecondEmailSentAt: true,
customChatsLimit: true,
},
},
},
})
const workspace = typebot?.workspace
if (!workspace) return false
const chatsLimit = getChatsLimit(workspace)
if (chatsLimit === -1) return
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const firstDayOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1)
const chatsCount = await prisma.$transaction(async (tx) => {
const typebotIds = await tx.typebot.findMany({
where: {
workspaceId: workspace.id,
},
select: { id: true },
})
return tx.result.count({
where: {
typebotId: { in: typebotIds.map((typebot) => typebot.id) },
hasStarted: true,
createdAt: { gte: firstDayOfMonth, lte: firstDayOfNextMonth },
},
})
})
const hasSentFirstEmail =
workspace.chatsLimitFirstEmailSentAt !== null &&
workspace.chatsLimitFirstEmailSentAt < firstDayOfNextMonth &&
workspace.chatsLimitFirstEmailSentAt > firstDayOfMonth
const hasSentSecondEmail =
workspace.chatsLimitSecondEmailSentAt !== null &&
workspace.chatsLimitSecondEmailSentAt < firstDayOfNextMonth &&
workspace.chatsLimitSecondEmailSentAt > firstDayOfMonth
if (
chatsCount >= chatsLimit * LIMIT_EMAIL_TRIGGER_PERCENT &&
!hasSentFirstEmail &&
env('E2E_TEST') !== 'true'
)
await sendAlmostReachChatsLimitNotification({
workspaceId: workspace.id,
chatsLimit,
})
if (
chatsCount >= chatsLimit &&
!hasSentSecondEmail &&
env('E2E_TEST') !== 'true'
)
await sendReachedAlertNotification({
workspaceId: workspace.id,
chatsLimit,
})
return chatsCount >= chatsLimit
}
const sendAlmostReachChatsLimitNotification = async ({
workspaceId,
chatsLimit,
}: {
workspaceId: string
chatsLimit: number
}) => {
const members = await prisma.memberInWorkspace.findMany({
where: { role: WorkspaceRole.ADMIN, workspaceId },
include: { user: { select: { email: true } } },
})
await sendAlmostReachedChatsLimitEmail({
to: members.map((member) => member.user.email).filter(isDefined),
chatsLimit,
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
})
await prisma.workspace.update({
where: { id: workspaceId },
data: { chatsLimitFirstEmailSentAt: new Date() },
})
}
const sendReachedAlertNotification = async ({
workspaceId,
chatsLimit,
}: {
workspaceId: string
chatsLimit: number
}) => {
const members = await prisma.memberInWorkspace.findMany({
where: { role: WorkspaceRole.ADMIN, workspaceId },
include: { user: { select: { email: true } } },
})
await sendReachedChatsLimitEmail({
to: members.map((member) => member.user.email).filter(isDefined),
chatsLimit,
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
})
await prisma.workspace.update({
where: { id: workspaceId },
data: { chatsLimitSecondEmailSentAt: new Date() },
})
}
export default handler

View File

@ -6,7 +6,7 @@ import { NextApiRequest, NextApiResponse } from 'next'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res, {
origin: 'https://docs.typebot.io',
origin: ['https://docs.typebot.io', 'http://localhost:3005'],
})
return createOpenApiNextHandler({

View File

@ -0,0 +1,85 @@
import { IncomingMessage } from 'http'
import { NotFoundPage } from '@/components/NotFoundPage'
import { GetServerSideProps, GetServerSidePropsContext } from 'next'
import { env, getViewerUrl, isNotDefined } from 'utils'
import prisma from '@/lib/prisma'
import { TypebotPageV2, TypebotPageV2Props } from '@/components/TypebotPageV2'
import { ErrorPage } from '@/components/ErrorPage'
export const getServerSideProps: GetServerSideProps = async (
context: GetServerSidePropsContext
) => {
const { host, forwardedHost } = getHost(context.req)
const pathname = context.resolvedUrl.split('?')[0]
try {
if (!host) return { props: {} }
const viewerUrls = (getViewerUrl({ returnAll: true }) ?? '').split(',')
const isMatchingViewerUrl =
env('E2E_TEST') === 'true'
? true
: viewerUrls.some(
(url) =>
host.split(':')[0].includes(url.split('//')[1].split(':')[0]) ||
(forwardedHost &&
forwardedHost
.split(':')[0]
.includes(url.split('//')[1].split(':')[0]))
)
const typebot = isMatchingViewerUrl
? await getTypebotFromPublicId(context.query.publicId?.toString())
: null
if (!typebot)
console.log(
isMatchingViewerUrl
? `Couldn't find publicId: ${context.query.publicId?.toString()}`
: `Couldn't find customDomain`
)
return {
props: {
typebot,
url: `https://${forwardedHost ?? host}${pathname}`,
},
}
} catch (err) {
console.error(err)
}
return {
props: {},
url: `https://${forwardedHost ?? host}${pathname}`,
}
}
const getTypebotFromPublicId = async (
publicId?: string
): Promise<TypebotPageV2Props['typebot'] | null> => {
if (!publicId) return null
const typebot = (await prisma.typebot.findUnique({
where: { publicId },
select: {
id: true,
theme: true,
name: true,
settings: true,
isArchived: true,
isClosed: true,
},
})) as TypebotPageV2Props['typebot'] | null
if (isNotDefined(typebot)) return null
return typebot
}
const getHost = (
req?: IncomingMessage
): { host?: string; forwardedHost?: string } => ({
host: req?.headers ? req.headers.host : window.location.host,
forwardedHost: req?.headers['x-forwarded-host'] as string | undefined,
})
const App = ({ typebot, url }: TypebotPageV2Props) => {
if (!typebot || typebot.isArchived) return <NotFoundPage />
if (typebot.isClosed)
return <ErrorPage error={new Error('This bot is now closed')} />
return <TypebotPageV2 typebot={typebot} url={url} />
}
export default App

View File

@ -0,0 +1,28 @@
import { InitialChatReply, SendMessageInput } from 'models'
import { sendRequest } from 'utils'
type Props = {
typebotId: string
resultId?: string
prefilledVariables?: Record<string, string>
}
export async function getInitialChatReplyQuery({
typebotId,
resultId,
prefilledVariables,
}: Props) {
if (!typebotId)
throw new Error('Typebot ID is required to get initial messages')
return sendRequest<InitialChatReply>({
method: 'POST',
url: `/api/v1/sendMessage`,
body: {
startParams: {
typebotId,
resultId,
prefilledVariables,
},
} satisfies SendMessageInput,
})
}

View File

@ -1,37 +1,56 @@
{
"id": "cl0ibhv8d0130n21aw8doxhj5",
"createdAt": "2022-03-08T15:59:06.589Z",
"updatedAt": "2022-03-08T15:59:10.498Z",
"name": "Another typebot",
"id": "clbovazhy000t3b6ok2deqnsw",
"createdAt": "2022-12-15T09:17:00.598Z",
"updatedAt": "2022-12-15T09:17:15.366Z",
"icon": null,
"name": "Another typebot copy",
"publishedTypebotId": null,
"folderId": null,
"groups": [
{
"id": "p4ByLVoKiDRyRoPHKmcTfw",
"id": "clbovazhy000q3b6o716dlfq8",
"title": "Start",
"blocks": [
{
"id": "rw6smEWEJzHKbiVKLUKFvZ",
"type": "start",
"label": "Start",
"groupId": "p4ByLVoKiDRyRoPHKmcTfw",
"outgoingEdgeId": "1z3pfiatTUHbraD2uSoA3E"
"groupId": "clbovazhy000q3b6o716dlfq8",
"outgoingEdgeId": "clbovazhy000s3b6ocjqtqedw"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "bg4QEJseUsTP496H27j5k2",
"graphCoordinates": { "x": 366, "y": 191 },
"id": "clbovazhy000r3b6onjzopbsn",
"title": "Group #1",
"blocks": [
{
"id": "s8ZeBL9p5za77eBmdKECLYq",
"groupId": "bg4QEJseUsTP496H27j5k2",
"type": "text input",
"groupId": "clbovazhy000r3b6onjzopbsn",
"options": {
"isLong": false,
"labels": { "button": "Send", "placeholder": "Type your answer..." }
},
"outgoingEdgeId": "clbovbaw600123b6o3i4ouzoq"
}
],
"graphCoordinates": { "x": 366, "y": 191 }
},
{
"id": "clbovb3vu00103b6o1pjjuagi",
"graphCoordinates": { "x": 740, "y": 288 },
"title": "Group #2",
"blocks": [
{
"id": "clbovb3vv00113b6oaa35zfvm",
"groupId": "clbovb3vu00103b6o1pjjuagi",
"type": "text",
"content": {
"html": "<div>Cheers!</div>",
"richText": [{ "type": "p", "children": [{ "text": "Cheers!" }] }],
"plainText": "Cheers!"
}
}
]
@ -40,12 +59,20 @@
"variables": [],
"edges": [
{
"id": "clbovazhy000s3b6ocjqtqedw",
"to": { "groupId": "clbovazhy000r3b6onjzopbsn" },
"from": {
"groupId": "p4ByLVoKiDRyRoPHKmcTfw",
"blockId": "rw6smEWEJzHKbiVKLUKFvZ"
"blockId": "rw6smEWEJzHKbiVKLUKFvZ",
"groupId": "clbovazhy000q3b6o716dlfq8"
}
},
{
"from": {
"groupId": "clbovazhy000r3b6onjzopbsn",
"blockId": "s8ZeBL9p5za77eBmdKECLYq"
},
"to": { "groupId": "bg4QEJseUsTP496H27j5k2" },
"id": "1z3pfiatTUHbraD2uSoA3E"
"to": { "groupId": "clbovb3vu00103b6o1pjjuagi" },
"id": "clbovbaw600123b6o3i4ouzoq"
}
],
"theme": {
@ -56,6 +83,10 @@
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostAvatar": {
"url": "https://avatars.githubusercontent.com/u/16015833?v=4",
"isEnabled": true
},
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
@ -72,5 +103,9 @@
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null
"customDomain": null,
"workspaceId": "proWorkspace",
"resultsTablePreferences": null,
"isArchived": false,
"isClosed": false
}

View File

@ -1,65 +1,85 @@
{
"id": "cl1rxxg6l334509lhv44f8qnx",
"createdAt": "2022-04-09T14:16:43.053Z",
"updatedAt": "2022-04-12T14:34:44.287Z",
"id": "clbnrow4e000h3b6o4gu6q0eo",
"createdAt": "2022-12-14T14:48:04.766Z",
"updatedAt": "2022-12-14T14:48:19.086Z",
"icon": null,
"name": "My typebot",
"name": "My typebot copy",
"publishedTypebotId": null,
"folderId": null,
"groups": [
{
"id": "cl1rxxg6k000009lhd0mgfy5i",
"id": "clbnrow4e000c3b6oycsv9cu3",
"title": "Start",
"blocks": [
{
"id": "cl1rxxg6k000109lh2is0gfua",
"type": "start",
"label": "Start",
"groupId": "cl1rxxg6k000009lhd0mgfy5i",
"outgoingEdgeId": "cl1w8rhzs000f2e694836a1k3"
"groupId": "clbnrow4e000c3b6oycsv9cu3",
"outgoingEdgeId": "clbnrow4e000f3b6ofulsqfj9"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "cl1w8repd000b2e69fwiqsd00",
"graphCoordinates": { "x": 364, "y": -2 },
"id": "clbnrow4e000d3b6o7ma9ikmt",
"title": "Group #1",
"blocks": [
{
"id": "cl1w8repg000c2e699jqwrepg",
"groupId": "cl1w8repd000b2e69fwiqsd00",
"type": "choice input",
"options": { "buttonLabel": "Send", "isMultipleChoice": false },
"items": [
{
"id": "cl1w8repg000d2e69d8xnkqeq",
"blockId": "cl1w8repg000c2e699jqwrepg",
"type": 0,
"blockId": "cl1w8repg000c2e699jqwrepg",
"content": "Send email",
"outgoingEdgeId": "cl1w8rkoo000i2e69hs60pk0q"
"outgoingEdgeId": "clbnrow4e000g3b6oo62hh39h"
}
]
],
"groupId": "clbnrow4e000d3b6o7ma9ikmt",
"options": { "buttonLabel": "Send", "isMultipleChoice": false }
}
]
],
"graphCoordinates": { "x": 364, "y": -2 }
},
{
"id": "cl1w8rjaf000g2e69cqd2bwvk",
"graphCoordinates": { "x": 715, "y": -10 },
"id": "clbnrow4e000e3b6ohe6yxtj6",
"title": "Group #2",
"blocks": [
{
"id": "cl1w8rjai000h2e695uvoimq7",
"groupId": "cl1w8rjaf000g2e69cqd2bwvk",
"type": "Email",
"groupId": "clbnrow4e000e3b6ohe6yxtj6",
"options": {
"credentialsId": "send-email-credentials",
"recipients": ["baptiste.arnaud95@gmail.com"],
"replyTo": "contact@baptiste-arnaud.fr",
"cc": ["test1@gmail.com", "test2@gmail.com"],
"bcc": ["test3@gmail.com", "test4@gmail.com"],
"body": "Test email",
"replyTo": "contact@baptiste-arnaud.fr",
"subject": "Hey!",
"body": "Test email"
"recipients": ["baptiste.arnaud95@gmail.com"],
"credentialsId": "send-email-credentials"
},
"outgoingEdgeId": "clbnrp5wn000q3b6o5k21zfvh"
}
],
"graphCoordinates": { "x": 715, "y": -10 }
},
{
"id": "clbnrp1kt000o3b6o2bh5ny0r",
"graphCoordinates": { "x": 1052.88671875, "y": -20.20703125 },
"title": "Group #3",
"blocks": [
{
"id": "clbnrp1ku000p3b6ouq1uit3r",
"groupId": "clbnrp1kt000o3b6o2bh5ny0r",
"type": "text",
"content": {
"html": "<div>Email sent!</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Email sent!" }] }
],
"plainText": "Email sent!"
}
}
]
@ -71,21 +91,29 @@
],
"edges": [
{
"id": "clbnrow4e000f3b6ofulsqfj9",
"to": { "groupId": "clbnrow4e000d3b6o7ma9ikmt" },
"from": {
"groupId": "cl1rxxg6k000009lhd0mgfy5i",
"blockId": "cl1rxxg6k000109lh2is0gfua"
},
"to": { "groupId": "cl1w8repd000b2e69fwiqsd00" },
"id": "cl1w8rhzs000f2e694836a1k3"
"blockId": "cl1rxxg6k000109lh2is0gfua",
"groupId": "clbnrow4e000c3b6oycsv9cu3"
}
},
{
"id": "clbnrow4e000g3b6oo62hh39h",
"to": { "groupId": "clbnrow4e000e3b6ohe6yxtj6" },
"from": {
"itemId": "cl1w8repg000d2e69d8xnkqeq",
"blockId": "cl1w8repg000c2e699jqwrepg",
"groupId": "clbnrow4e000d3b6o7ma9ikmt"
}
},
{
"from": {
"groupId": "cl1w8repd000b2e69fwiqsd00",
"blockId": "cl1w8repg000c2e699jqwrepg",
"itemId": "cl1w8repg000d2e69d8xnkqeq"
"groupId": "clbnrow4e000e3b6ohe6yxtj6",
"blockId": "cl1w8rjai000h2e695uvoimq7"
},
"to": { "groupId": "cl1w8rjaf000g2e69cqd2bwvk" },
"id": "cl1w8rkoo000i2e69hs60pk0q"
"to": { "groupId": "clbnrp1kt000o3b6o2bh5ny0r" },
"id": "clbnrp5wn000q3b6o5k21zfvh"
}
],
"theme": {
@ -117,5 +145,9 @@
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null
"customDomain": null,
"workspaceId": "proWorkspace",
"resultsTablePreferences": null,
"isArchived": false,
"isClosed": false
}

View File

@ -0,0 +1,17 @@
const sessionStorageKey = 'resultId'
export const getExistingResultFromSession = () => {
try {
return sessionStorage.getItem(sessionStorageKey)
} catch {
/* empty */
}
}
export const setResultInSession = (resultId: string) => {
try {
return sessionStorage.setItem(sessionStorageKey, resultId)
} catch {
/* empty */
}
}