@ -14,7 +14,9 @@ Sentry.init({
|
||||
hint?.event.target.innerText
|
||||
}`
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
/* empty */
|
||||
}
|
||||
return breadcrumb
|
||||
},
|
||||
})
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -2,7 +2,6 @@ import {
|
||||
useDisclosure,
|
||||
Flex,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
Input,
|
||||
PopoverContent,
|
||||
Button,
|
||||
|
@ -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
|
||||
}>({})
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
|
@ -115,7 +115,7 @@ export const SendEmailSettings = ({ options, onOptionsChange }: Props) => {
|
||||
onCredentialsSelect={handleCredentialsSelect}
|
||||
onCreateNewClick={onOpen}
|
||||
defaultCredentialLabel={env('SMTP_FROM')
|
||||
?.match(/\<(.*)\>/)
|
||||
?.match(/<(.*)>/)
|
||||
?.pop()}
|
||||
refreshDropdownKey={refreshCredentialsKey}
|
||||
/>
|
||||
|
@ -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) {
|
||||
|
@ -8,7 +8,6 @@ import {
|
||||
MenuItem,
|
||||
MenuList,
|
||||
SkeletonCircle,
|
||||
SkeletonText,
|
||||
Text,
|
||||
Tag,
|
||||
Flex,
|
||||
|
@ -11,7 +11,7 @@ export const useTypebots = ({
|
||||
}) => {
|
||||
const { data, isLoading, refetch } = trpc.typebot.listTypebots.useQuery(
|
||||
{
|
||||
workspaceId: workspaceId!,
|
||||
workspaceId: workspaceId as string,
|
||||
folderId,
|
||||
},
|
||||
{
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
}>({})
|
||||
|
||||
|
@ -88,6 +88,7 @@ const typebotContext = createContext<
|
||||
ItemsActions &
|
||||
VariablesActions &
|
||||
EdgesActions
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
>({})
|
||||
|
||||
|
@ -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
|
||||
}>({})
|
||||
|
||||
|
@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
}>({})
|
||||
|
||||
|
@ -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 }),
|
||||
|
@ -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
|
||||
}>({})
|
||||
|
||||
|
@ -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
|
||||
}>({})
|
||||
|
||||
|
@ -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 (
|
||||
|
@ -3,7 +3,6 @@ import {
|
||||
Checkbox,
|
||||
Flex,
|
||||
Skeleton,
|
||||
useColorMode,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react'
|
||||
import React from 'react'
|
||||
|
@ -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')
|
||||
|
@ -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(
|
||||
|
@ -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 }
|
||||
)
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -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 },
|
||||
})
|
||||
|
@ -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'
|
||||
|
@ -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) => {
|
||||
|
@ -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) => {
|
||||
|
@ -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
@ -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.
|
||||
|
@ -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:*",
|
||||
|
@ -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 {}
|
||||
}
|
||||
|
110
apps/viewer/src/components/TypebotPageV2.tsx
Normal file
110
apps/viewer/src/components/TypebotPageV2.tsx
Normal 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
|
||||
}
|
@ -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)
|
||||
|
@ -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()
|
||||
})
|
||||
})
|
@ -0,0 +1 @@
|
||||
export * from './utils'
|
@ -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: '₫',
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './computePaymentInputRuntimeOptions'
|
@ -1 +1 @@
|
||||
export { validatePhoneNumber } from './utils/validatePhoneNumber'
|
||||
export * from './utils'
|
||||
|
@ -0,0 +1,4 @@
|
||||
import phone from 'phone'
|
||||
|
||||
export const formatPhoneNumber = (phoneNumber: string) =>
|
||||
phone(phoneNumber).phoneNumber
|
@ -0,0 +1,2 @@
|
||||
export * from './formatPhoneNumber'
|
||||
export * from './validatePhoneNumber'
|
@ -1,4 +1,4 @@
|
||||
const phoneRegex = /^\+?[0-9]{6,}$/
|
||||
import { phone } from 'phone'
|
||||
|
||||
export const validatePhoneNumber = (phoneNumber: string) =>
|
||||
phoneRegex.test(phoneNumber)
|
||||
phone(phoneNumber).isValid
|
||||
|
@ -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()
|
||||
})
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
}
|
||||
|
@ -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 }
|
||||
}
|
||||
|
@ -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
|
||||
> => {
|
||||
|
@ -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()
|
||||
})
|
@ -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 }
|
||||
|
@ -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 () => {
|
||||
|
@ -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()
|
||||
})
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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 () => {
|
||||
|
@ -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()
|
||||
})
|
@ -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'>
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
|
@ -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()
|
||||
|
27
apps/viewer/src/features/results/resultsV2.spec.ts
Normal file
27
apps/viewer/src/features/results/resultsV2.spec.ts
Normal 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()
|
||||
})
|
179
apps/viewer/src/features/settings/settingsV2.spec.ts
Normal file
179
apps/viewer/src/features/settings/settingsV2.spec.ts
Normal 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')
|
||||
})
|
1
apps/viewer/src/features/usage/index.ts
Normal file
1
apps/viewer/src/features/usage/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './utils'
|
70
apps/viewer/src/features/usage/usageV2.spec.ts
Normal file
70
apps/viewer/src/features/usage/usageV2.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
130
apps/viewer/src/features/usage/utils/checkChatsUsage.ts
Normal file
130
apps/viewer/src/features/usage/utils/checkChatsUsage.ts
Normal 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() },
|
||||
})
|
||||
}
|
1
apps/viewer/src/features/usage/utils/index.ts
Normal file
1
apps/viewer/src/features/usage/utils/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './checkChatsUsage'
|
@ -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),
|
||||
|
21
apps/viewer/src/features/variables/variablesV2.spec.ts
Normal file
21
apps/viewer/src/features/variables/variablesV2.spec.ts
Normal 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'
|
||||
)
|
||||
})
|
@ -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
|
||||
|
@ -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({
|
||||
|
85
apps/viewer/src/pages/next/[[...publicId]].tsx
Normal file
85
apps/viewer/src/pages/next/[[...publicId]].tsx
Normal 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
|
28
apps/viewer/src/queries/getInitialChatReplyQuery.ts
Normal file
28
apps/viewer/src/queries/getInitialChatReplyQuery.ts
Normal 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,
|
||||
})
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
17
apps/viewer/src/utils/sessionStorage.ts
Normal file
17
apps/viewer/src/utils/sessionStorage.ts
Normal 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 */
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user