From ee3b94c35d670d4c514082d70542a2e0d50ae1c4 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Thu, 24 Aug 2023 07:48:30 +0200 Subject: [PATCH] :zap: (typebotLink) Better typebot link with merge option Closes #675 --- .../public/templates/customer-support.json | 6 +- apps/builder/public/templates/faq.json | 3 +- apps/builder/public/templates/quiz.json | 3 +- .../typebotLink/api/getLinkedTypebots.ts | 3 +- .../components/TypebotLinkForm.tsx | 48 ++- .../components/TypebotLinkNode.tsx | 22 +- .../components/TypebotsDropdown.tsx | 12 +- .../editor/components/TypebotHeader.tsx | 21 +- .../editor/providers/TypebotProvider.tsx | 34 +-- .../src/features/results/ResultsProvider.tsx | 37 ++- .../table/ExportAllResultsModal.tsx | 18 +- .../features/typebot/helpers/parseNewBlock.ts | 3 +- ...injectVariableValuesInButtonsInputBlock.ts | 14 +- .../computePaymentInputRuntimeOptions.ts | 11 +- ...njectVariableValuesInPictureChoiceBlock.ts | 4 +- .../chatwoot/executeChatwootBlock.ts | 7 +- .../executeGoogleAnalyticsBlock.ts | 7 +- .../integrations/googleSheets/getRow.ts | 8 +- .../integrations/googleSheets/insertRow.ts | 3 +- .../integrations/googleSheets/updateRow.ts | 3 +- .../openai/createChatCompletionOpenAI.ts | 27 +- .../openai/getChatCompletionStream.ts | 5 +- .../openai/resumeChatCompletion.ts | 5 +- .../integrations/pixel/executePixelBlock.ts | 7 +- .../sendEmail/executeSendEmailBlock.tsx | 61 ++-- .../webhook/executeWebhookBlock.ts | 91 +++--- .../webhook/resumeWebhookExecution.ts | 5 +- .../logic/condition/executeConditionBlock.ts | 3 +- .../blocks/logic/jump/executeJumpBlock.ts | 5 +- .../blocks/logic/redirect/executeRedirect.ts | 3 +- .../blocks/logic/script/executeScript.ts | 3 +- .../logic/setVariable/executeSetVariable.ts | 10 +- .../logic/typebotLink/executeTypebotLink.ts | 187 ++++++++---- .../logic/typebotLink/typebotLink.spec.ts | 26 ++ .../features/blocks/logic/wait/executeWait.ts | 3 +- .../src/features/chat/api/sendMessage.ts | 62 ++-- .../chat/api/updateTypebotInSession.ts | 38 ++- .../features/chat/helpers/addEdgeToTypebot.ts | 15 +- .../features/chat/helpers/continueBotFlow.ts | 52 ++-- .../src/features/chat/helpers/executeGroup.ts | 39 +-- .../src/features/chat/helpers/getNextGroup.ts | 78 +++-- .../chat/helpers/saveStateToDatabase.ts | 18 +- .../src/features/chat/helpers/startBotFlow.ts | 7 +- .../chat/queries/createResultIfNotExist.ts | 33 ++ .../src/features/chat/queries/findResult.ts | 17 +- .../src/features/chat/queries/getSession.ts | 9 +- .../src/features/chat/queries/upsertAnswer.ts | 7 +- .../src/features/chat/queries/upsertResult.ts | 32 +- .../src/features/variables/updateVariables.ts | 47 +-- .../pages/api/integrations/openai/streamer.ts | 2 +- .../blocks/[blockId]/executeWebhook.ts | 283 +++++++++--------- .../[typebotId]/integrations/email.tsx | 19 +- .../linkTypebots/1-merge-disabled.json | 75 +++++ packages/lib/results.ts | 82 +++-- .../features/blocks/logic/typebotLink.ts | 5 +- .../features/{chat.ts => chat/schema.ts} | 69 +---- .../schemas/features/chat/sessionState.ts | 125 ++++++++ packages/schemas/features/chat/shared.ts | 17 ++ packages/schemas/index.ts | 4 +- 59 files changed, 1147 insertions(+), 696 deletions(-) create mode 100644 apps/viewer/src/features/chat/queries/createResultIfNotExist.ts create mode 100644 apps/viewer/src/test/assets/typebots/linkTypebots/1-merge-disabled.json rename packages/schemas/features/{chat.ts => chat/schema.ts} (79%) create mode 100644 packages/schemas/features/chat/sessionState.ts create mode 100644 packages/schemas/features/chat/shared.ts diff --git a/apps/builder/public/templates/customer-support.json b/apps/builder/public/templates/customer-support.json index 85229781b..8d8819260 100644 --- a/apps/builder/public/templates/customer-support.json +++ b/apps/builder/public/templates/customer-support.json @@ -180,7 +180,8 @@ "type": "Typebot link", "options": { "typebotId": "current", - "groupId": "vLUAPaxKwPF49iZhg4XZYa" + "groupId": "vLUAPaxKwPF49iZhg4XZYa", + "mergeResults": false } } ], @@ -305,7 +306,8 @@ "groupId": "1GvxCAAEysxJMxrVngud3X", "options": { "groupId": "vLUAPaxKwPF49iZhg4XZYa", - "typebotId": "current" + "typebotId": "current", + "mergeResults": false } } ], diff --git a/apps/builder/public/templates/faq.json b/apps/builder/public/templates/faq.json index 219d4fca2..7c904a86a 100644 --- a/apps/builder/public/templates/faq.json +++ b/apps/builder/public/templates/faq.json @@ -282,7 +282,8 @@ "type": "Typebot link", "options": { "typebotId": "current", - "groupId": "cl96ns9qr00043b6ii07bo25o" + "groupId": "cl96ns9qr00043b6ii07bo25o", + "mergeResults": false } } ] diff --git a/apps/builder/public/templates/quiz.json b/apps/builder/public/templates/quiz.json index 878c3066f..819660239 100644 --- a/apps/builder/public/templates/quiz.json +++ b/apps/builder/public/templates/quiz.json @@ -618,7 +618,8 @@ "groupId": "cl1r15f68005f2e6dvdtal7cp", "options": { "groupId": "cl1r09bc6000h2e6dqml18p4p", - "typebotId": "current" + "typebotId": "current", + "mergeResults": false } } ], diff --git a/apps/builder/src/features/blocks/logic/typebotLink/api/getLinkedTypebots.ts b/apps/builder/src/features/blocks/logic/typebotLink/api/getLinkedTypebots.ts index eddc52bda..9bdfdfa18 100644 --- a/apps/builder/src/features/blocks/logic/typebotLink/api/getLinkedTypebots.ts +++ b/apps/builder/src/features/blocks/logic/typebotLink/api/getLinkedTypebots.ts @@ -65,7 +65,8 @@ export const getLinkedTypebots = authenticatedProcedure (typebotIds, block) => block.type === LogicBlockType.TYPEBOT_LINK && isDefined(block.options.typebotId) && - !typebotIds.includes(block.options.typebotId) + !typebotIds.includes(block.options.typebotId) && + block.options.mergeResults !== false ? [...typebotIds, block.options.typebotId] : typebotIds, [] diff --git a/apps/builder/src/features/blocks/logic/typebotLink/components/TypebotLinkForm.tsx b/apps/builder/src/features/blocks/logic/typebotLink/components/TypebotLinkForm.tsx index bb062836c..4b87c29d5 100644 --- a/apps/builder/src/features/blocks/logic/typebotLink/components/TypebotLinkForm.tsx +++ b/apps/builder/src/features/blocks/logic/typebotLink/components/TypebotLinkForm.tsx @@ -1,10 +1,11 @@ import { Stack } from '@chakra-ui/react' import { useTypebot } from '@/features/editor/providers/TypebotProvider' import { TypebotLinkOptions } from '@typebot.io/schemas' -import { byId } from '@typebot.io/lib' import { GroupsDropdown } from './GroupsDropdown' import { TypebotsDropdown } from './TypebotsDropdown' -import { useEffect, useState } from 'react' +import { trpc } from '@/lib/trpc' +import { isNotEmpty } from '@typebot.io/lib' +import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel' type Props = { options: TypebotLinkOptions @@ -12,21 +13,30 @@ type Props = { } export const TypebotLinkForm = ({ options, onOptionsChange }: Props) => { - const { linkedTypebots, typebot, save } = useTypebot() - const [linkedTypebotId, setLinkedTypebotId] = useState(options.typebotId) + const { typebot } = useTypebot() const handleTypebotIdChange = async ( typebotId: string | 'current' | undefined - ) => onOptionsChange({ ...options, typebotId }) + ) => onOptionsChange({ ...options, typebotId, groupId: undefined }) + + const { data: linkedTypebotData } = trpc.typebot.getTypebot.useQuery( + { + typebotId: options.typebotId as string, + }, + { + enabled: isNotEmpty(options.typebotId) && options.typebotId !== 'current', + } + ) const handleGroupIdChange = (groupId: string | undefined) => onOptionsChange({ ...options, groupId }) - useEffect(() => { - if (linkedTypebotId === options.typebotId) return - setLinkedTypebotId(options.typebotId) - save().then() - }, [linkedTypebotId, options.typebotId, save]) + const updateMergeResults = (mergeResults: boolean) => + onOptionsChange({ ...options, mergeResults }) + + const isCurrentTypebotSelected = + (typebot && options.typebotId === typebot.id) || + options.typebotId === 'current' return ( @@ -40,22 +50,30 @@ export const TypebotLinkForm = ({ options, onOptionsChange }: Props) => { )} {options.typebotId && ( )} + {!isCurrentTypebotSelected && ( + + )} ) } diff --git a/apps/builder/src/features/blocks/logic/typebotLink/components/TypebotLinkNode.tsx b/apps/builder/src/features/blocks/logic/typebotLink/components/TypebotLinkNode.tsx index 47dad2de7..4d6e2d0c2 100644 --- a/apps/builder/src/features/blocks/logic/typebotLink/components/TypebotLinkNode.tsx +++ b/apps/builder/src/features/blocks/logic/typebotLink/components/TypebotLinkNode.tsx @@ -2,24 +2,36 @@ import { TypebotLinkBlock } from '@typebot.io/schemas' import React from 'react' import { Tag, Text } from '@chakra-ui/react' import { useTypebot } from '@/features/editor/providers/TypebotProvider' -import { byId } from '@typebot.io/lib' +import { byId, isNotEmpty } from '@typebot.io/lib' +import { trpc } from '@/lib/trpc' type Props = { block: TypebotLinkBlock } export const TypebotLinkNode = ({ block }: Props) => { - const { linkedTypebots, typebot } = useTypebot() + const { typebot } = useTypebot() + + const { data: linkedTypebotData } = trpc.typebot.getTypebot.useQuery( + { + typebotId: block.options.typebotId as string, + }, + { + enabled: + isNotEmpty(block.options.typebotId) && + block.options.typebotId !== 'current', + } + ) + const isCurrentTypebot = typebot && (block.options.typebotId === typebot.id || block.options.typebotId === 'current') - const linkedTypebot = isCurrentTypebot - ? typebot - : linkedTypebots?.find(byId(block.options.typebotId)) + const linkedTypebot = isCurrentTypebot ? typebot : linkedTypebotData?.typebot const blockTitle = linkedTypebot?.groups.find( byId(block.options.groupId) )?.title + if (!block.options.typebotId) return Configure... return ( diff --git a/apps/builder/src/features/blocks/logic/typebotLink/components/TypebotsDropdown.tsx b/apps/builder/src/features/blocks/logic/typebotLink/components/TypebotsDropdown.tsx index 0267268cd..dfa787fdc 100644 --- a/apps/builder/src/features/blocks/logic/typebotLink/components/TypebotsDropdown.tsx +++ b/apps/builder/src/features/blocks/logic/typebotLink/components/TypebotsDropdown.tsx @@ -61,7 +61,17 @@ export const TypebotsDropdown = ({ aria-label="Navigate to typebot" icon={} as={Link} - href={`/typebots/${typebotId}/edit?parentId=${query.typebotId}`} + href={{ + pathname: '/typebots/[typebotId]/edit', + query: { + typebotId, + parentId: query.parentId + ? Array.isArray(query.parentId) + ? query.parentId.concat(query.typebotId?.toString() ?? '') + : [query.parentId, query.typebotId?.toString() ?? ''] + : query.typebotId ?? [], + }, + }} /> )} diff --git a/apps/builder/src/features/editor/components/TypebotHeader.tsx b/apps/builder/src/features/editor/components/TypebotHeader.tsx index 405461e4e..352de1581 100644 --- a/apps/builder/src/features/editor/components/TypebotHeader.tsx +++ b/apps/builder/src/features/editor/components/TypebotHeader.tsx @@ -156,13 +156,22 @@ export const TypebotHeader = () => { as={Link} aria-label="Navigate back" icon={} - href={ - router.query.parentId - ? `/typebots/${router.query.parentId}/edit` + href={{ + pathname: router.query.parentId + ? '/typebots/[typebotId]/edit' : typebot?.folderId - ? `/typebots/folders/${typebot.folderId}` - : '/typebots' - } + ? '/typebots/folders/[folderId]' + : '/typebots', + query: { + folderId: typebot?.folderId ?? [], + parentId: Array.isArray(router.query.parentId) + ? router.query.parentId.slice(0, -1) + : [], + typebotId: Array.isArray(router.query.parentId) + ? [...router.query.parentId].pop() + : router.query.parentId ?? [], + }, + }} size="sm" /> diff --git a/apps/builder/src/features/editor/providers/TypebotProvider.tsx b/apps/builder/src/features/editor/providers/TypebotProvider.tsx index 3a46d1d0d..cf2de545f 100644 --- a/apps/builder/src/features/editor/providers/TypebotProvider.tsx +++ b/apps/builder/src/features/editor/providers/TypebotProvider.tsx @@ -1,4 +1,4 @@ -import { LogicBlockType, PublicTypebot, Typebot } from '@typebot.io/schemas' +import { PublicTypebot, Typebot } from '@typebot.io/schemas' import { Router, useRouter } from 'next/router' import { createContext, @@ -49,7 +49,6 @@ const typebotContext = createContext< { typebot?: Typebot publishedTypebot?: PublicTypebot - linkedTypebots?: Pick[] isReadOnly?: boolean isPublished: boolean isSavingLoading: boolean @@ -132,36 +131,6 @@ export const TypebotProvider = ({ { redo, undo, flush, canRedo, canUndo, set: setLocalTypebot }, ] = useUndo(undefined) - const linkedTypebotIds = useMemo( - () => - typebot?.groups - .flatMap((group) => group.blocks) - .reduce( - (typebotIds, block) => - block.type === LogicBlockType.TYPEBOT_LINK && - isDefined(block.options.typebotId) && - !typebotIds.includes(block.options.typebotId) - ? [...typebotIds, block.options.typebotId] - : typebotIds, - [] - ) ?? [], - [typebot?.groups] - ) - - const { data: linkedTypebotsData } = trpc.getLinkedTypebots.useQuery( - { - typebotId: typebot?.id as string, - }, - { - enabled: isDefined(typebot?.id) && linkedTypebotIds.length > 0, - onError: (error) => - showToast({ - title: 'Error while fetching linkedTypebots', - description: error.message, - }), - } - ) - useEffect(() => { if (!typebot && isDefined(localTypebot)) setLocalTypebot(undefined) if (isFetchingTypebot) return @@ -270,7 +239,6 @@ export const TypebotProvider = ({ value={{ typebot: localTypebot, publishedTypebot, - linkedTypebots: linkedTypebotsData?.typebots ?? [], isReadOnly: typebotData?.isReadOnly, isSavingLoading: isSaving, save: saveTypebot, diff --git a/apps/builder/src/features/results/ResultsProvider.tsx b/apps/builder/src/features/results/ResultsProvider.tsx index 2b1e3d05a..22ef938ac 100644 --- a/apps/builder/src/features/results/ResultsProvider.tsx +++ b/apps/builder/src/features/results/ResultsProvider.tsx @@ -1,11 +1,17 @@ import { useToast } from '@/hooks/useToast' -import { ResultHeaderCell, ResultWithAnswers } from '@typebot.io/schemas' +import { + LogicBlockType, + ResultHeaderCell, + ResultWithAnswers, +} from '@typebot.io/schemas' import { createContext, ReactNode, useContext, useMemo } from 'react' import { parseResultHeader } from '@typebot.io/lib/results' import { useTypebot } from '../editor/providers/TypebotProvider' import { useResultsQuery } from './hooks/useResultsQuery' import { TableData } from './types' import { convertResultsToTableData } from './helpers/convertResultsToTableData' +import { trpc } from '@/lib/trpc' +import { isDefined } from '@typebot.io/lib/utils' const resultsContext = createContext<{ resultsList: { results: ResultWithAnswers[] }[] | undefined @@ -32,7 +38,7 @@ export const ResultsProvider = ({ totalResults: number onDeleteResults: (totalResultsDeleted: number) => void }) => { - const { publishedTypebot, linkedTypebots } = useTypebot() + const { publishedTypebot } = useTypebot() const { showToast } = useToast() const { data, fetchNextPage, hasNextPage, refetch } = useResultsQuery({ typebotId, @@ -41,6 +47,29 @@ export const ResultsProvider = ({ }, }) + const linkedTypebotIds = + publishedTypebot?.groups + .flatMap((group) => group.blocks) + .reduce( + (typebotIds, block) => + block.type === LogicBlockType.TYPEBOT_LINK && + isDefined(block.options.typebotId) && + !typebotIds.includes(block.options.typebotId) && + block.options.mergeResults !== false + ? [...typebotIds, block.options.typebotId] + : typebotIds, + [] + ) ?? [] + + const { data: linkedTypebotsData } = trpc.getLinkedTypebots.useQuery( + { + typebotId, + }, + { + enabled: linkedTypebotIds.length > 0, + } + ) + const flatResults = useMemo( () => data?.flatMap((d) => d.results) ?? [], [data] @@ -49,9 +78,9 @@ export const ResultsProvider = ({ const resultHeader = useMemo( () => publishedTypebot - ? parseResultHeader(publishedTypebot, linkedTypebots) + ? parseResultHeader(publishedTypebot, linkedTypebotsData?.typebots) : [], - [linkedTypebots, publishedTypebot] + [linkedTypebotsData?.typebots, publishedTypebot] ) const tableData = useMemo( diff --git a/apps/builder/src/features/results/components/table/ExportAllResultsModal.tsx b/apps/builder/src/features/results/components/table/ExportAllResultsModal.tsx index c6def732f..bb3fe90c8 100644 --- a/apps/builder/src/features/results/components/table/ExportAllResultsModal.tsx +++ b/apps/builder/src/features/results/components/table/ExportAllResultsModal.tsx @@ -23,6 +23,7 @@ import { useResults } from '../../ResultsProvider' import { parseColumnOrder } from '../../helpers/parseColumnsOrder' import { convertResultsToTableData } from '../../helpers/convertResultsToTableData' import { parseAccessor } from '../../helpers/parseAccessor' +import { isDefined } from '@typebot.io/lib' type Props = { isOpen: boolean @@ -30,7 +31,7 @@ type Props = { } export const ExportAllResultsModal = ({ isOpen, onClose }: Props) => { - const { typebot, publishedTypebot, linkedTypebots } = useTypebot() + const { typebot, publishedTypebot } = useTypebot() const workspaceId = typebot?.workspaceId const typebotId = typebot?.id const { showToast } = useToast() @@ -41,6 +42,15 @@ export const ExportAllResultsModal = ({ isOpen, onClose }: Props) => { const [areDeletedBlocksIncluded, setAreDeletedBlocksIncluded] = useState(false) + const { data: linkedTypebotsData } = trpc.getLinkedTypebots.useQuery( + { + typebotId: typebotId as string, + }, + { + enabled: isDefined(typebotId), + } + ) + const getAllResults = async () => { if (!workspaceId || !typebotId) return [] const allResults = [] @@ -71,7 +81,11 @@ export const ExportAllResultsModal = ({ isOpen, onClose }: Props) => { const results = await getAllResults() const resultHeader = areDeletedBlocksIncluded - ? parseResultHeader(publishedTypebot, linkedTypebots, results) + ? parseResultHeader( + publishedTypebot, + linkedTypebotsData?.typebots, + results + ) : existingResultHeader const dataToUnparse = convertResultsToTableData(results, resultHeader) diff --git a/apps/builder/src/features/typebot/helpers/parseNewBlock.ts b/apps/builder/src/features/typebot/helpers/parseNewBlock.ts index a648f0bba..8833e30e1 100644 --- a/apps/builder/src/features/typebot/helpers/parseNewBlock.ts +++ b/apps/builder/src/features/typebot/helpers/parseNewBlock.ts @@ -43,6 +43,7 @@ import { LogicBlockType, defaultAbTestOptions, BlockWithItems, + defaultTypebotLinkOptions, } from '@typebot.io/schemas' import { defaultPictureChoiceOptions } from '@typebot.io/schemas/features/blocks/inputs/pictureChoice' @@ -122,7 +123,7 @@ const parseDefaultBlockOptions = (type: BlockWithOptionsType): BlockOptions => { case LogicBlockType.JUMP: return {} case LogicBlockType.TYPEBOT_LINK: - return {} + return defaultTypebotLinkOptions case LogicBlockType.AB_TEST: return defaultAbTestOptions case IntegrationBlockType.GOOGLE_SHEETS: diff --git a/apps/viewer/src/features/blocks/inputs/buttons/injectVariableValuesInButtonsInputBlock.ts b/apps/viewer/src/features/blocks/inputs/buttons/injectVariableValuesInButtonsInputBlock.ts index 3e88445c1..313fe5278 100644 --- a/apps/viewer/src/features/blocks/inputs/buttons/injectVariableValuesInButtonsInputBlock.ts +++ b/apps/viewer/src/features/blocks/inputs/buttons/injectVariableValuesInButtonsInputBlock.ts @@ -13,8 +13,9 @@ import { filterChoiceItems } from './filterChoiceItems' export const injectVariableValuesInButtonsInputBlock = (state: SessionState) => (block: ChoiceInputBlock): ChoiceInputBlock => { + const { variables } = state.typebotsQueue[0].typebot if (block.options.dynamicVariableId) { - const variable = state.typebot.variables.find( + const variable = variables.find( (variable) => variable.id === block.options.dynamicVariableId && isDefined(variable.value) @@ -31,18 +32,17 @@ export const injectVariableValuesInButtonsInputBlock = })), } } - return deepParseVariables(state.typebot.variables)( - filterChoiceItems(state.typebot.variables)(block) - ) + return deepParseVariables(variables)(filterChoiceItems(variables)(block)) } const getVariableValue = (state: SessionState) => (variable: VariableWithValue): (string | null)[] => { if (!Array.isArray(variable.value)) { - const [transformedVariable] = transformStringVariablesToList( - state.typebot.variables - )([variable.id]) + const { variables } = state.typebotsQueue[0].typebot + const [transformedVariable] = transformStringVariablesToList(variables)([ + variable.id, + ]) updateVariables(state)([transformedVariable]) return transformedVariable.value as string[] } diff --git a/apps/viewer/src/features/blocks/inputs/payment/computePaymentInputRuntimeOptions.ts b/apps/viewer/src/features/blocks/inputs/payment/computePaymentInputRuntimeOptions.ts index 48dd884cb..20549d27e 100644 --- a/apps/viewer/src/features/blocks/inputs/payment/computePaymentInputRuntimeOptions.ts +++ b/apps/viewer/src/features/blocks/inputs/payment/computePaymentInputRuntimeOptions.ts @@ -11,18 +11,17 @@ import Stripe from 'stripe' import { decrypt } from '@typebot.io/lib/api/encryption' export const computePaymentInputRuntimeOptions = - (state: Pick) => - (options: PaymentInputOptions) => + (state: SessionState) => (options: PaymentInputOptions) => createStripePaymentIntent(state)(options) const createStripePaymentIntent = - (state: Pick) => + (state: SessionState) => async (options: PaymentInputOptions): Promise => { const { - result, + resultId, typebot: { variables }, - } = state - const isPreview = !result.id + } = state.typebotsQueue[0] + const isPreview = !resultId if (!options.credentialsId) throw new TRPCError({ code: 'BAD_REQUEST', diff --git a/apps/viewer/src/features/blocks/inputs/pictureChoice/injectVariableValuesInPictureChoiceBlock.ts b/apps/viewer/src/features/blocks/inputs/pictureChoice/injectVariableValuesInPictureChoiceBlock.ts index 00341935f..d5fd257ce 100644 --- a/apps/viewer/src/features/blocks/inputs/pictureChoice/injectVariableValuesInPictureChoiceBlock.ts +++ b/apps/viewer/src/features/blocks/inputs/pictureChoice/injectVariableValuesInPictureChoiceBlock.ts @@ -1,15 +1,15 @@ import { - SessionState, VariableWithValue, ItemType, PictureChoiceBlock, + Variable, } from '@typebot.io/schemas' import { isDefined } from '@typebot.io/lib' import { deepParseVariables } from '@/features/variables/deepParseVariable' import { filterPictureChoiceItems } from './filterPictureChoiceItems' export const injectVariableValuesInPictureChoiceBlock = - (variables: SessionState['typebot']['variables']) => + (variables: Variable[]) => (block: PictureChoiceBlock): PictureChoiceBlock => { if ( block.options.dynamicItems?.isEnabled && diff --git a/apps/viewer/src/features/blocks/integrations/chatwoot/executeChatwootBlock.ts b/apps/viewer/src/features/blocks/integrations/chatwoot/executeChatwootBlock.ts index 2760cc5aa..c0ecc0b3b 100644 --- a/apps/viewer/src/features/blocks/integrations/chatwoot/executeChatwootBlock.ts +++ b/apps/viewer/src/features/blocks/integrations/chatwoot/executeChatwootBlock.ts @@ -70,17 +70,18 @@ if (window.$chatwoot) { ` export const executeChatwootBlock = ( - { typebot, result }: SessionState, + state: SessionState, block: ChatwootBlock ): ExecuteIntegrationResponse => { + const { typebot, resultId } = state.typebotsQueue[0] const chatwootCode = block.options.task === 'Close widget' ? chatwootCloseCode - : isDefined(result.id) + : isDefined(resultId) ? parseChatwootOpenCode({ ...block.options, typebotId: typebot.id, - resultId: result.id, + resultId, }) : '' return { diff --git a/apps/viewer/src/features/blocks/integrations/googleAnalytics/executeGoogleAnalyticsBlock.ts b/apps/viewer/src/features/blocks/integrations/googleAnalytics/executeGoogleAnalyticsBlock.ts index 9ef6637e6..06a2506b3 100644 --- a/apps/viewer/src/features/blocks/integrations/googleAnalytics/executeGoogleAnalyticsBlock.ts +++ b/apps/viewer/src/features/blocks/integrations/googleAnalytics/executeGoogleAnalyticsBlock.ts @@ -3,11 +3,12 @@ import { deepParseVariables } from '@/features/variables/deepParseVariable' import { GoogleAnalyticsBlock, SessionState } from '@typebot.io/schemas' export const executeGoogleAnalyticsBlock = ( - { typebot: { variables }, result }: SessionState, + state: SessionState, block: GoogleAnalyticsBlock ): ExecuteIntegrationResponse => { - if (!result) return { outgoingEdgeId: block.outgoingEdgeId } - const googleAnalytics = deepParseVariables(variables, { + const { typebot, resultId } = state.typebotsQueue[0] + if (!resultId) return { outgoingEdgeId: block.outgoingEdgeId } + const googleAnalytics = deepParseVariables(typebot.variables, { guessCorrectTypes: true, removeEmptyStrings: true, })(block.options) diff --git a/apps/viewer/src/features/blocks/integrations/googleSheets/getRow.ts b/apps/viewer/src/features/blocks/integrations/googleSheets/getRow.ts index 5e2b0a4e6..82b67ea12 100644 --- a/apps/viewer/src/features/blocks/integrations/googleSheets/getRow.ts +++ b/apps/viewer/src/features/blocks/integrations/googleSheets/getRow.ts @@ -19,13 +19,11 @@ export const getRow = async ( }: { outgoingEdgeId?: string; options: GoogleSheetsGetOptions } ): Promise => { const logs: ReplyLog[] = [] - const { sheetId, cellsToExtract, referenceCell, filter } = deepParseVariables( - state.typebot.variables - )(options) + const { variables } = state.typebotsQueue[0].typebot + const { sheetId, cellsToExtract, referenceCell, filter } = + deepParseVariables(variables)(options) if (!sheetId) return { outgoingEdgeId } - const variables = state.typebot.variables - const doc = await getAuthenticatedGoogleDoc({ credentialsId: options.credentialsId, spreadsheetId: options.spreadsheetId, diff --git a/apps/viewer/src/features/blocks/integrations/googleSheets/insertRow.ts b/apps/viewer/src/features/blocks/integrations/googleSheets/insertRow.ts index 0055f4b15..bbdff7216 100644 --- a/apps/viewer/src/features/blocks/integrations/googleSheets/insertRow.ts +++ b/apps/viewer/src/features/blocks/integrations/googleSheets/insertRow.ts @@ -8,12 +8,13 @@ import { getAuthenticatedGoogleDoc } from './helpers/getAuthenticatedGoogleDoc' import { ExecuteIntegrationResponse } from '@/features/chat/types' export const insertRow = async ( - { typebot: { variables } }: SessionState, + state: SessionState, { outgoingEdgeId, options, }: { outgoingEdgeId?: string; options: GoogleSheetsInsertRowOptions } ): Promise => { + const { variables } = state.typebotsQueue[0].typebot if (!options.cellsToInsert || !options.sheetId) return { outgoingEdgeId } const logs: ReplyLog[] = [] diff --git a/apps/viewer/src/features/blocks/integrations/googleSheets/updateRow.ts b/apps/viewer/src/features/blocks/integrations/googleSheets/updateRow.ts index c75b80951..6b5f16052 100644 --- a/apps/viewer/src/features/blocks/integrations/googleSheets/updateRow.ts +++ b/apps/viewer/src/features/blocks/integrations/googleSheets/updateRow.ts @@ -10,12 +10,13 @@ import { ExecuteIntegrationResponse } from '@/features/chat/types' import { matchFilter } from './helpers/matchFilter' export const updateRow = async ( - { typebot: { variables } }: SessionState, + state: SessionState, { outgoingEdgeId, options, }: { outgoingEdgeId?: string; options: GoogleSheetsUpdateRowOptions } ): Promise => { + const { variables } = state.typebotsQueue[0].typebot const { sheetId, referenceCell, filter } = deepParseVariables(variables)(options) if (!options.cellsToUpsert || !sheetId || (!referenceCell && !filter)) diff --git a/apps/viewer/src/features/blocks/integrations/openai/createChatCompletionOpenAI.ts b/apps/viewer/src/features/blocks/integrations/openai/createChatCompletionOpenAI.ts index b407d7b0e..248368411 100644 --- a/apps/viewer/src/features/blocks/integrations/openai/createChatCompletionOpenAI.ts +++ b/apps/viewer/src/features/blocks/integrations/openai/createChatCompletionOpenAI.ts @@ -1,6 +1,11 @@ import { ExecuteIntegrationResponse } from '@/features/chat/types' import prisma from '@/lib/prisma' -import { Block, BubbleBlockType, SessionState } from '@typebot.io/schemas' +import { + Block, + BubbleBlockType, + SessionState, + TypebotInSession, +} from '@typebot.io/schemas' import { ChatCompletionOpenAIOptions, OpenAICredentials, @@ -51,13 +56,16 @@ export const createChatCompletionOpenAI = async ( credentials.data, credentials.iv )) as OpenAICredentials['data'] + + const { typebot } = newSessionState.typebotsQueue[0] + const { variablesTransformedToList, messages } = parseChatCompletionMessages( - newSessionState.typebot.variables + typebot.variables )(options.messages) if (variablesTransformedToList.length > 0) newSessionState = updateVariables(state)(variablesTransformedToList) - const temperature = parseVariableNumber(newSessionState.typebot.variables)( + const temperature = parseVariableNumber(typebot.variables)( options.advancedSettings?.temperature ) @@ -66,7 +74,7 @@ export const createChatCompletionOpenAI = async ( isCredentialsV2(credentials) && newSessionState.isStreamEnabled ) { - const assistantMessageVariableName = state.typebot.variables.find( + const assistantMessageVariableName = typebot.variables.find( (variable) => options.responseMapping.find( (m) => m.valueToExtract === 'Message content' @@ -81,9 +89,10 @@ export const createChatCompletionOpenAI = async ( content?: string role: (typeof chatCompletionMessageRoles)[number] }[], - displayStream: isNextBubbleMessageWithAssistantMessage( - state.typebot - )(blockId, assistantMessageVariableName), + displayStream: isNextBubbleMessageWithAssistantMessage(typebot)( + blockId, + assistantMessageVariableName + ), }, }, ], @@ -117,7 +126,7 @@ export const createChatCompletionOpenAI = async ( } const isNextBubbleMessageWithAssistantMessage = - (typebot: SessionState['typebot']) => + (typebot: TypebotInSession) => (blockId: string, assistantVariableName?: string): boolean => { if (!assistantVariableName) return false const nextBlock = getNextBlock(typebot)(blockId) @@ -131,7 +140,7 @@ const isNextBubbleMessageWithAssistantMessage = } const getNextBlock = - (typebot: SessionState['typebot']) => + (typebot: TypebotInSession) => (blockId: string): Block | undefined => { const group = typebot.groups.find((group) => group.blocks.find(byId(blockId)) diff --git a/apps/viewer/src/features/blocks/integrations/openai/getChatCompletionStream.ts b/apps/viewer/src/features/blocks/integrations/openai/getChatCompletionStream.ts index fb3c5d3bb..55bccc595 100644 --- a/apps/viewer/src/features/blocks/integrations/openai/getChatCompletionStream.ts +++ b/apps/viewer/src/features/blocks/integrations/openai/getChatCompletionStream.ts @@ -5,7 +5,7 @@ import { ChatCompletionOpenAIOptions, OpenAICredentials, } from '@typebot.io/schemas/features/blocks/integrations/openai' -import { SessionState } from '@typebot.io/schemas/features/chat' +import { SessionState } from '@typebot.io/schemas/features/chat/sessionState' import { OpenAIStream } from 'ai' import { ChatCompletionRequestMessage, @@ -35,7 +35,8 @@ export const getChatCompletionStream = credentials.iv )) as OpenAICredentials['data'] - const temperature = parseVariableNumber(state.typebot.variables)( + const { typebot } = state.typebotsQueue[0] + const temperature = parseVariableNumber(typebot.variables)( options.advancedSettings?.temperature ) diff --git a/apps/viewer/src/features/blocks/integrations/openai/resumeChatCompletion.ts b/apps/viewer/src/features/blocks/integrations/openai/resumeChatCompletion.ts index 113237962..79eb99038 100644 --- a/apps/viewer/src/features/blocks/integrations/openai/resumeChatCompletion.ts +++ b/apps/viewer/src/features/blocks/integrations/openai/resumeChatCompletion.ts @@ -22,9 +22,8 @@ export const resumeChatCompletion = const newVariables = options.responseMapping.reduce< VariableWithUnknowValue[] >((newVariables, mapping) => { - const existingVariable = newSessionState.typebot.variables.find( - byId(mapping.variableId) - ) + const { typebot } = newSessionState.typebotsQueue[0] + const existingVariable = typebot.variables.find(byId(mapping.variableId)) if (!existingVariable) return newVariables if (mapping.valueToExtract === 'Message content') { newVariables.push({ diff --git a/apps/viewer/src/features/blocks/integrations/pixel/executePixelBlock.ts b/apps/viewer/src/features/blocks/integrations/pixel/executePixelBlock.ts index bdfd0989b..b0074378b 100644 --- a/apps/viewer/src/features/blocks/integrations/pixel/executePixelBlock.ts +++ b/apps/viewer/src/features/blocks/integrations/pixel/executePixelBlock.ts @@ -3,12 +3,13 @@ import { deepParseVariables } from '@/features/variables/deepParseVariable' import { PixelBlock, SessionState } from '@typebot.io/schemas' export const executePixelBlock = ( - { typebot: { variables }, result }: SessionState, + state: SessionState, block: PixelBlock ): ExecuteIntegrationResponse => { - if (!result || !block.options.pixelId || !block.options.eventType) + const { typebot, resultId } = state.typebotsQueue[0] + if (!resultId || !block.options.pixelId || !block.options.eventType) return { outgoingEdgeId: block.outgoingEdgeId } - const pixel = deepParseVariables(variables, { + const pixel = deepParseVariables(typebot.variables, { guessCorrectTypes: true, removeEmptyStrings: true, })(block.options) diff --git a/apps/viewer/src/features/blocks/integrations/sendEmail/executeSendEmailBlock.tsx b/apps/viewer/src/features/blocks/integrations/sendEmail/executeSendEmailBlock.tsx index a93e418e3..219bb410a 100644 --- a/apps/viewer/src/features/blocks/integrations/sendEmail/executeSendEmailBlock.tsx +++ b/apps/viewer/src/features/blocks/integrations/sendEmail/executeSendEmailBlock.tsx @@ -3,32 +3,32 @@ import prisma from '@/lib/prisma' import { render } from '@faire/mjml-react/utils/render' import { DefaultBotNotificationEmail } from '@typebot.io/emails' import { - PublicTypebot, + AnswerInSessionState, ReplyLog, - ResultInSession, SendEmailBlock, SendEmailOptions, SessionState, SmtpCredentials, + TypebotInSession, Variable, } from '@typebot.io/schemas' import { createTransport } from 'nodemailer' import Mail from 'nodemailer/lib/mailer' import { byId, isDefined, isEmpty, isNotDefined, omit } from '@typebot.io/lib' -import { parseAnswers } from '@typebot.io/lib/results' +import { getDefinedVariables, parseAnswers } from '@typebot.io/lib/results' import { decrypt } from '@typebot.io/lib/api' import { defaultFrom, defaultTransportOptions } from './constants' import { ExecuteIntegrationResponse } from '@/features/chat/types' import { findUniqueVariableValue } from '../../../variables/findUniqueVariableValue' export const executeSendEmailBlock = async ( - { result, typebot }: SessionState, + state: SessionState, block: SendEmailBlock ): Promise => { const logs: ReplyLog[] = [] const { options } = block - const { variables } = typebot - const isPreview = !result.id + const { typebot, resultId, answers } = state.typebotsQueue[0] + const isPreview = !resultId if (isPreview) return { outgoingEdgeId: block.outgoingEdgeId, @@ -41,23 +41,23 @@ export const executeSendEmailBlock = async ( } const body = - findUniqueVariableValue(variables)(options.body)?.toString() ?? - parseVariables(variables, { escapeHtml: true })(options.body ?? '') + findUniqueVariableValue(typebot.variables)(options.body)?.toString() ?? + parseVariables(typebot.variables, { escapeHtml: true })(options.body ?? '') try { const sendEmailLogs = await sendEmail({ - typebotId: typebot.id, - result, + typebot, + answers, credentialsId: options.credentialsId, - recipients: options.recipients.map(parseVariables(variables)), - subject: parseVariables(variables)(options.subject ?? ''), + recipients: options.recipients.map(parseVariables(typebot.variables)), + subject: parseVariables(typebot.variables)(options.subject ?? ''), body, - cc: (options.cc ?? []).map(parseVariables(variables)), - bcc: (options.bcc ?? []).map(parseVariables(variables)), + cc: (options.cc ?? []).map(parseVariables(typebot.variables)), + bcc: (options.bcc ?? []).map(parseVariables(typebot.variables)), replyTo: options.replyTo - ? parseVariables(variables)(options.replyTo) + ? parseVariables(typebot.variables)(options.replyTo) : undefined, - fileUrls: getFileUrls(variables)(options.attachmentsVariableId), + fileUrls: getFileUrls(typebot.variables)(options.attachmentsVariableId), isCustomBody: options.isCustomBody, isBodyCode: options.isBodyCode, }) @@ -74,8 +74,8 @@ export const executeSendEmailBlock = async ( } const sendEmail = async ({ - typebotId, - result, + typebot, + answers, credentialsId, recipients, body, @@ -87,8 +87,8 @@ const sendEmail = async ({ isCustomBody, fileUrls, }: SendEmailOptions & { - typebotId: string - result: ResultInSession + typebot: TypebotInSession + answers: AnswerInSessionState[] fileUrls?: string | string[] }): Promise => { const logs: ReplyLog[] = [] @@ -112,8 +112,8 @@ const sendEmail = async ({ body, isCustomBody, isBodyCode, - typebotId, - result, + typebot, + answersInSession: answers, }) if (!emailBody) { @@ -206,11 +206,11 @@ const getEmailBody = async ({ body, isCustomBody, isBodyCode, - typebotId, - result, + typebot, + answersInSession, }: { - typebotId: string - result: ResultInSession + typebot: TypebotInSession + answersInSession: AnswerInSessionState[] } & Pick): Promise< { html?: string; text?: string } | undefined > => { @@ -219,11 +219,10 @@ const getEmailBody = async ({ html: isBodyCode ? body : undefined, text: !isBodyCode ? body : undefined, } - const typebot = (await prisma.publicTypebot.findUnique({ - where: { typebotId }, - })) as unknown as PublicTypebot - if (!typebot) return - const answers = parseAnswers(typebot, [])(result) + const answers = parseAnswers({ + variables: getDefinedVariables(typebot.variables), + answers: answersInSession, + }) return { html: render( => { - const { typebot, result } = state const logs: ReplyLog[] = [] const webhook = block.options.webhook ?? @@ -52,9 +48,8 @@ export const executeWebhookBlock = async ( } const preparedWebhook = prepareWebhookAttributes(webhook, block.options) const parsedWebhook = await parseWebhookAttributes( - typebot, - block.groupId, - result + state, + state.typebotsQueue[0].answers )(preparedWebhook) if (!parsedWebhook) { logs.push({ @@ -97,14 +92,10 @@ const prepareWebhookAttributes = ( const checkIfBodyIsAVariable = (body: string) => /^{{.+}}$/.test(body) const parseWebhookAttributes = - ( - typebot: SessionState['typebot'], - groupId: string, - result: ResultInSession - ) => + (state: SessionState, answers: AnswerInSessionState[]) => async (webhook: Webhook): Promise => { if (!webhook.url || !webhook.method) return - const { variables } = typebot + const { typebot } = state.typebotsQueue[0] const basicAuth: { username?: string; password?: string } = {} const basicAuthHeaderIdx = webhook.headers.findIndex( (h) => @@ -121,32 +112,29 @@ const parseWebhookAttributes = basicAuth.password = password webhook.headers.splice(basicAuthHeaderIdx, 1) } - const headers = convertKeyValueTableToObject(webhook.headers, variables) as - | ExecutableWebhook['headers'] - | undefined + const headers = convertKeyValueTableToObject( + webhook.headers, + typebot.variables + ) as ExecutableWebhook['headers'] | undefined const queryParams = stringify( - convertKeyValueTableToObject(webhook.queryParams, variables) + convertKeyValueTableToObject(webhook.queryParams, typebot.variables) ) - const bodyContent = await getBodyContent( - typebot, - [] - )({ + const bodyContent = await getBodyContent({ body: webhook.body, - result, - groupId, - variables, + answers, + variables: typebot.variables, }) const { data: body, isJson } = bodyContent && webhook.method !== HttpMethod.GET ? safeJsonParse( - parseVariables(variables, { + parseVariables(typebot.variables, { escapeForJson: !checkIfBodyIsAVariable(bodyContent), })(bodyContent) ) : { data: undefined, isJson: false } return { - url: parseVariables(variables)( + url: parseVariables(typebot.variables)( webhook.url + (queryParams !== '' ? `?${queryParams}` : '') ), basicAuth, @@ -229,34 +217,25 @@ export const executeWebhook = async ( } } -const getBodyContent = - ( - typebot: Pick, - linkedTypebots: (Typebot | PublicTypebot)[] - ) => - async ({ - body, - result, - groupId, - variables, - }: { - body?: string | null - result?: ResultInSession - groupId: string - variables: Variable[] - }): Promise => { - if (!body) return - return body === '{{state}}' - ? JSON.stringify( - result - ? parseAnswers(typebot, linkedTypebots)(result) - : await parseSampleResult(typebot, linkedTypebots)( - groupId, - variables - ) - ) - : body - } +const getBodyContent = async ({ + body, + answers, + variables, +}: { + body?: string | null + answers: AnswerInSessionState[] + variables: Variable[] +}): Promise => { + if (!body) return + return body === '{{state}}' + ? JSON.stringify( + parseAnswers({ + answers, + variables: getDefinedVariables(variables), + }) + ) + : body +} const convertKeyValueTableToObject = ( keyValues: KeyValue[] | undefined, diff --git a/apps/viewer/src/features/blocks/integrations/webhook/resumeWebhookExecution.ts b/apps/viewer/src/features/blocks/integrations/webhook/resumeWebhookExecution.ts index 753a870a6..295ab2321 100644 --- a/apps/viewer/src/features/blocks/integrations/webhook/resumeWebhookExecution.ts +++ b/apps/viewer/src/features/blocks/integrations/webhook/resumeWebhookExecution.ts @@ -5,11 +5,12 @@ import { byId } from '@typebot.io/lib' import { MakeComBlock, PabblyConnectBlock, + ReplyLog, VariableWithUnknowValue, WebhookBlock, ZapierBlock, } from '@typebot.io/schemas' -import { ReplyLog, SessionState } from '@typebot.io/schemas/features/chat' +import { SessionState } from '@typebot.io/schemas/features/chat/sessionState' type Props = { state: SessionState @@ -27,7 +28,7 @@ export const resumeWebhookExecution = ({ logs = [], response, }: Props): ExecuteIntegrationResponse => { - const { typebot } = state + const { typebot } = state.typebotsQueue[0] const status = response.statusCode.toString() const isError = status.startsWith('4') || status.startsWith('5') diff --git a/apps/viewer/src/features/blocks/logic/condition/executeConditionBlock.ts b/apps/viewer/src/features/blocks/logic/condition/executeConditionBlock.ts index b42aa835e..289541086 100644 --- a/apps/viewer/src/features/blocks/logic/condition/executeConditionBlock.ts +++ b/apps/viewer/src/features/blocks/logic/condition/executeConditionBlock.ts @@ -3,9 +3,10 @@ import { ExecuteLogicResponse } from '@/features/chat/types' import { executeCondition } from './executeCondition' export const executeConditionBlock = ( - { typebot: { variables } }: SessionState, + state: SessionState, block: ConditionBlock ): ExecuteLogicResponse => { + const { variables } = state.typebotsQueue[0].typebot const passedCondition = block.items.find((item) => executeCondition(variables)(item.content) ) diff --git a/apps/viewer/src/features/blocks/logic/jump/executeJumpBlock.ts b/apps/viewer/src/features/blocks/logic/jump/executeJumpBlock.ts index 8a7b93a11..f46d0ab17 100644 --- a/apps/viewer/src/features/blocks/logic/jump/executeJumpBlock.ts +++ b/apps/viewer/src/features/blocks/logic/jump/executeJumpBlock.ts @@ -11,9 +11,8 @@ export const executeJumpBlock = ( state: SessionState, { groupId, blockId }: JumpBlock['options'] ): ExecuteLogicResponse => { - const groupToJumpTo = state.typebot.groups.find( - (group) => group.id === groupId - ) + const { typebot } = state.typebotsQueue[0] + const groupToJumpTo = typebot.groups.find((group) => group.id === groupId) const blockToJumpTo = groupToJumpTo?.blocks.find((block) => block.id === blockId) ?? groupToJumpTo?.blocks[0] diff --git a/apps/viewer/src/features/blocks/logic/redirect/executeRedirect.ts b/apps/viewer/src/features/blocks/logic/redirect/executeRedirect.ts index 1e676f845..96a2a842a 100644 --- a/apps/viewer/src/features/blocks/logic/redirect/executeRedirect.ts +++ b/apps/viewer/src/features/blocks/logic/redirect/executeRedirect.ts @@ -4,9 +4,10 @@ import { sanitizeUrl } from '@typebot.io/lib' import { ExecuteLogicResponse } from '@/features/chat/types' export const executeRedirect = ( - { typebot: { variables } }: SessionState, + state: SessionState, block: RedirectBlock ): ExecuteLogicResponse => { + const { variables } = state.typebotsQueue[0].typebot if (!block.options?.url) return { outgoingEdgeId: block.outgoingEdgeId } const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url)) return { diff --git a/apps/viewer/src/features/blocks/logic/script/executeScript.ts b/apps/viewer/src/features/blocks/logic/script/executeScript.ts index 0adb36a8b..9d3ab4dd9 100644 --- a/apps/viewer/src/features/blocks/logic/script/executeScript.ts +++ b/apps/viewer/src/features/blocks/logic/script/executeScript.ts @@ -5,9 +5,10 @@ import { parseVariables } from '@/features/variables/parseVariables' import { ScriptBlock, SessionState, Variable } from '@typebot.io/schemas' export const executeScript = ( - { typebot: { variables } }: SessionState, + state: SessionState, block: ScriptBlock ): ExecuteLogicResponse => { + const { variables } = state.typebotsQueue[0].typebot if (!block.options.content) return { outgoingEdgeId: block.outgoingEdgeId } const scriptToExecute = parseScriptToExecuteClientSideAction( diff --git a/apps/viewer/src/features/blocks/logic/setVariable/executeSetVariable.ts b/apps/viewer/src/features/blocks/logic/setVariable/executeSetVariable.ts index 570e13889..0828b18fb 100644 --- a/apps/viewer/src/features/blocks/logic/setVariable/executeSetVariable.ts +++ b/apps/viewer/src/features/blocks/logic/setVariable/executeSetVariable.ts @@ -10,14 +10,14 @@ export const executeSetVariable = ( state: SessionState, block: SetVariableBlock ): ExecuteLogicResponse => { - const { variables } = state.typebot + const { variables } = state.typebotsQueue[0].typebot if (!block.options?.variableId) return { outgoingEdgeId: block.outgoingEdgeId, } - const expressionToEvaluate = getExpressionToEvaluate(state.result.id)( - block.options - ) + const expressionToEvaluate = getExpressionToEvaluate( + state.typebotsQueue[0].resultId + )(block.options) const isCustomValue = !block.options.type || block.options.type === 'Custom' if ( expressionToEvaluate && @@ -25,7 +25,7 @@ export const executeSetVariable = ( block.options.type === 'Moment of the day') ) { const scriptToExecute = parseScriptToExecuteClientSideAction( - state.typebot.variables, + variables, expressionToEvaluate ) return { diff --git a/apps/viewer/src/features/blocks/logic/typebotLink/executeTypebotLink.ts b/apps/viewer/src/features/blocks/logic/typebotLink/executeTypebotLink.ts index 058d26fb3..3d0f83799 100644 --- a/apps/viewer/src/features/blocks/logic/typebotLink/executeTypebotLink.ts +++ b/apps/viewer/src/features/blocks/logic/typebotLink/executeTypebotLink.ts @@ -6,19 +6,24 @@ import prisma from '@/lib/prisma' import { TypebotLinkBlock, SessionState, - TypebotInSession, Variable, ReplyLog, + Typebot, + VariableWithValue, + Edge, } from '@typebot.io/schemas' -import { byId } from '@typebot.io/lib' import { ExecuteLogicResponse } from '@/features/chat/types' +import { createId } from '@paralleldrive/cuid2' +import { isDefined, isNotDefined } from '@typebot.io/lib/utils' +import { createResultIfNotExist } from '@/features/chat/queries/createResultIfNotExist' export const executeTypebotLink = async ( state: SessionState, block: TypebotLinkBlock ): Promise => { const logs: ReplyLog[] = [] - if (!block.options.typebotId) { + const typebotId = block.options.typebotId + if (!typebotId) { logs.push({ status: 'error', description: `Failed to link typebot`, @@ -26,7 +31,7 @@ export const executeTypebotLink = async ( }) return { outgoingEdgeId: block.outgoingEdgeId, logs } } - const linkedTypebot = await getLinkedTypebot(state, block.options.typebotId) + const linkedTypebot = await fetchTypebot(state, typebotId) if (!linkedTypebot) { logs.push({ status: 'error', @@ -35,12 +40,17 @@ export const executeTypebotLink = async ( }) return { outgoingEdgeId: block.outgoingEdgeId, logs } } - let newSessionState = addLinkedTypebotToState(state, block, linkedTypebot) + let newSessionState = await addLinkedTypebotToState( + state, + block, + linkedTypebot + ) const nextGroupId = block.options.groupId ?? - linkedTypebot.groups.find((b) => b.blocks.some((s) => s.type === 'start')) - ?.id + linkedTypebot.groups.find((group) => + group.blocks.some((block) => block.type === 'start') + )?.id if (!nextGroupId) { logs.push({ status: 'error', @@ -60,76 +70,123 @@ export const executeTypebotLink = async ( } } -const addLinkedTypebotToState = ( +const addLinkedTypebotToState = async ( state: SessionState, block: TypebotLinkBlock, - linkedTypebot: TypebotInSession -): SessionState => { - const incomingVariables = fillVariablesWithExistingValues( - linkedTypebot.variables, - state.typebot.variables - ) + linkedTypebot: Pick +): Promise => { + const currentTypebotInQueue = state.typebotsQueue[0] + const isPreview = isNotDefined(currentTypebotInQueue.resultId) + + const resumeEdge = createResumeEdgeIfNecessary(state, block) + + const currentTypebotWithResumeEdge = resumeEdge + ? { + ...currentTypebotInQueue, + typebot: { + ...currentTypebotInQueue.typebot, + edges: [...currentTypebotInQueue.typebot.edges, resumeEdge], + }, + } + : currentTypebotInQueue + + const shouldMergeResults = + block.options.mergeResults !== false || + currentTypebotInQueue.typebot.id === linkedTypebot.id || + block.options.typebotId === 'current' + + if ( + currentTypebotInQueue.resultId && + currentTypebotInQueue.answers.length === 0 && + shouldMergeResults + ) { + await createResultIfNotExist({ + resultId: currentTypebotInQueue.resultId, + typebot: currentTypebotInQueue.typebot, + hasStarted: false, + isCompleted: false, + }) + } + return { ...state, - typebot: { - ...state.typebot, - groups: [...state.typebot.groups, ...linkedTypebot.groups], - variables: [...state.typebot.variables, ...incomingVariables], - edges: [...state.typebot.edges, ...linkedTypebot.edges], + typebotsQueue: [ + { + typebot: { + ...linkedTypebot, + variables: fillVariablesWithExistingValues( + linkedTypebot.variables, + currentTypebotInQueue.typebot.variables + ), + }, + resultId: isPreview + ? undefined + : shouldMergeResults + ? currentTypebotInQueue.resultId + : createId(), + edgeIdToTriggerWhenDone: block.outgoingEdgeId ?? resumeEdge?.id, + answers: shouldMergeResults ? currentTypebotInQueue.answers : [], + isMergingWithParent: shouldMergeResults, + }, + currentTypebotWithResumeEdge, + ...state.typebotsQueue.slice(1), + ], + } +} + +const createResumeEdgeIfNecessary = ( + state: SessionState, + block: TypebotLinkBlock +): Edge | undefined => { + const currentTypebotInQueue = state.typebotsQueue[0] + const blockId = block.id + if (block.outgoingEdgeId) return + const currentGroup = currentTypebotInQueue.typebot.groups.find((group) => + group.blocks.some((block) => block.id === blockId) + ) + if (!currentGroup) return + const currentBlockIndex = currentGroup.blocks.findIndex( + (block) => block.id === blockId + ) + const nextBlockInGroup = + currentBlockIndex === -1 + ? undefined + : currentGroup.blocks[currentBlockIndex + 1] + if (!nextBlockInGroup) return + return { + id: createId(), + from: { + groupId: '', + blockId: '', }, - 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, + to: { + groupId: nextBlockInGroup.groupId, + blockId: nextBlockInGroup.id, }, - currentTypebotId: linkedTypebot.id, } } const fillVariablesWithExistingValues = ( variables: Variable[], variablesWithValues: Variable[] -): Variable[] => - variables.map((variable) => { - const matchedVariable = variablesWithValues.find( - (variableWithValue) => variableWithValue.name === variable.name - ) +): VariableWithValue[] => + variables + .map((variable) => { + const matchedVariable = variablesWithValues.find( + (variableWithValue) => variableWithValue.name === variable.name + ) - return { - ...variable, - value: matchedVariable?.value ?? variable.value, - } - }) + return { + ...variable, + value: matchedVariable?.value, + } + }) + .filter((variable) => isDefined(variable.value)) as VariableWithValue[] -const getLinkedTypebot = async ( - state: SessionState, - typebotId: string -): Promise => { - const { typebot, result } = state - const isPreview = !result.id - if (typebotId === 'current') return typebot - const availableTypebots = - 'linkedTypebots' in state - ? [typebot, ...state.linkedTypebots.typebots] - : [typebot] - const linkedTypebot = - availableTypebots.find(byId(typebotId)) ?? - (await fetchTypebot(isPreview, typebotId)) - return linkedTypebot -} - -const fetchTypebot = async ( - isPreview: boolean, - typebotId: string -): Promise => { +const fetchTypebot = async (state: SessionState, typebotId: string) => { + const { typebot: typebotInState, resultId } = state.typebotsQueue[0] + const isPreview = !resultId + if (typebotId === 'current') return typebotInState if (isPreview) { const typebot = await prisma.typebot.findUnique({ where: { id: typebotId }, @@ -140,7 +197,7 @@ const fetchTypebot = async ( variables: true, }, }) - return typebot as TypebotInSession + return typebot as Pick } const typebot = await prisma.publicTypebot.findUnique({ where: { typebotId }, @@ -155,5 +212,5 @@ const fetchTypebot = async ( return { ...typebot, id: typebotId, - } as TypebotInSession + } as Pick } diff --git a/apps/viewer/src/features/blocks/logic/typebotLink/typebotLink.spec.ts b/apps/viewer/src/features/blocks/logic/typebotLink/typebotLink.spec.ts index c1e06e888..4383ecbc5 100644 --- a/apps/viewer/src/features/blocks/logic/typebotLink/typebotLink.spec.ts +++ b/apps/viewer/src/features/blocks/logic/typebotLink/typebotLink.spec.ts @@ -3,6 +3,7 @@ import test, { expect } from '@playwright/test' import { importTypebotInDatabase } from '@typebot.io/lib/playwright/databaseActions' const typebotId = 'cl0ibhi7s0018n21aarlmg0cm' +const typebotWithMergeDisabledId = 'cl0ibhi7s0018n21aarlag0cm' const linkedTypebotId = 'cl0ibhv8d0130n21aw8doxhj5' test.beforeAll(async () => { @@ -11,6 +12,13 @@ test.beforeAll(async () => { getTestAsset('typebots/linkTypebots/1.json'), { id: typebotId, publicId: `${typebotId}-public` } ) + await importTypebotInDatabase( + getTestAsset('typebots/linkTypebots/1-merge-disabled.json'), + { + id: typebotWithMergeDisabledId, + publicId: `${typebotWithMergeDisabledId}-public`, + } + ) await importTypebotInDatabase( getTestAsset('typebots/linkTypebots/2.json'), { id: linkedTypebotId, publicId: `${linkedTypebotId}-public` } @@ -28,3 +36,21 @@ test('should work as expected', async ({ page }) => { await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`) await expect(page.locator('text=Hello there!')).toBeVisible() }) + +test.describe('Merge disabled', () => { + test('should work as expected', async ({ page }) => { + await page.goto(`/${typebotWithMergeDisabledId}-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/${typebotWithMergeDisabledId}/results` + ) + await expect(page.locator('text=Submitted at')).toBeVisible() + await expect(page.locator('text=Hello there!')).toBeHidden() + await page.goto( + `${process.env.NEXTAUTH_URL}/typebots/${linkedTypebotId}/results` + ) + await expect(page.locator('text=Hello there!')).toBeVisible() + }) +}) diff --git a/apps/viewer/src/features/blocks/logic/wait/executeWait.ts b/apps/viewer/src/features/blocks/logic/wait/executeWait.ts index 0a02b7bfa..95e83d368 100644 --- a/apps/viewer/src/features/blocks/logic/wait/executeWait.ts +++ b/apps/viewer/src/features/blocks/logic/wait/executeWait.ts @@ -3,9 +3,10 @@ import { parseVariables } from '@/features/variables/parseVariables' import { SessionState, WaitBlock } from '@typebot.io/schemas' export const executeWait = ( - { typebot: { variables } }: SessionState, + state: SessionState, block: WaitBlock ): ExecuteLogicResponse => { + const { variables } = state.typebotsQueue[0].typebot if (!block.options.secondsToWaitFor) return { outgoingEdgeId: block.outgoingEdgeId } const parsedSecondsToWaitFor = safeParseInt( diff --git a/apps/viewer/src/features/chat/api/sendMessage.ts b/apps/viewer/src/features/chat/api/sendMessage.ts index 462a43b5f..a183ec47f 100644 --- a/apps/viewer/src/features/chat/api/sendMessage.ts +++ b/apps/viewer/src/features/chat/api/sendMessage.ts @@ -7,7 +7,6 @@ import { IntegrationBlockType, PixelBlock, ReplyLog, - ResultInSession, sendMessageInputSchema, SessionState, StartParams, @@ -148,22 +147,19 @@ const startSession = async ( : prefilledVariables const initialState: SessionState = { - typebot: { - id: typebot.id, - groups: typebot.groups, - edges: typebot.edges, - variables: startVariables, - }, - linkedTypebots: { - typebots: [], - queue: [], - }, - result: { - id: result?.id, - variables: result?.variables ?? [], - answers: result?.answers ?? [], - }, - currentTypebotId: typebot.id, + version: '2', + typebotsQueue: [ + { + resultId: result?.id, + typebot: { + id: typebot.id, + groups: typebot.groups, + edges: typebot.edges, + variables: startVariables, + }, + answers: [], + }, + ], dynamicTheme: parseDynamicThemeInState(typebot.theme), isStreamEnabled: startParams.isStreamEnabled, } @@ -212,12 +208,12 @@ const startSession = async ( startClientSideAction.length > 0 ? startClientSideAction : undefined, typebot: { id: typebot.id, - settings: deepParseVariables(newSessionState.typebot.variables)( - typebot.settings - ), - theme: deepParseVariables(newSessionState.typebot.variables)( - typebot.theme - ), + settings: deepParseVariables( + newSessionState.typebotsQueue[0].typebot.variables + )(typebot.settings), + theme: deepParseVariables( + newSessionState.typebotsQueue[0].typebot.variables + )(typebot.theme), }, dynamicTheme: parseDynamicThemeReply(newSessionState), logs: startLogs.length > 0 ? startLogs : undefined, @@ -239,12 +235,12 @@ const startSession = async ( sessionId: session.id, typebot: { id: typebot.id, - settings: deepParseVariables(newSessionState.typebot.variables)( - typebot.settings - ), - theme: deepParseVariables(newSessionState.typebot.variables)( - typebot.theme - ), + settings: deepParseVariables( + newSessionState.typebotsQueue[0].typebot.variables + )(typebot.settings), + theme: deepParseVariables( + newSessionState.typebotsQueue[0].typebot.variables + )(typebot.theme), }, messages, input, @@ -319,7 +315,7 @@ const getResult = async ({ if (isPreview) return const existingResult = resultId && isRememberUserEnabled - ? ((await findResult({ id: resultId })) as ResultInSession) + ? await findResult({ id: resultId }) : undefined const prefilledVariableWithValue = prefilledVariables.filter( @@ -341,7 +337,7 @@ const getResult = async ({ return { id: existingResult?.id ?? createId(), variables: updatedResult.variables, - answers: existingResult?.answers, + answers: existingResult?.answers ?? [], } } @@ -369,10 +365,10 @@ const parseDynamicThemeReply = ( ): ChatReply['dynamicTheme'] => { if (!state?.dynamicTheme) return return { - hostAvatarUrl: parseVariables(state?.typebot.variables)( + hostAvatarUrl: parseVariables(state.typebotsQueue[0].typebot.variables)( state.dynamicTheme.hostAvatarUrl ), - guestAvatarUrl: parseVariables(state?.typebot.variables)( + guestAvatarUrl: parseVariables(state.typebotsQueue[0].typebot.variables)( state.dynamicTheme.guestAvatarUrl ), } diff --git a/apps/viewer/src/features/chat/api/updateTypebotInSession.ts b/apps/viewer/src/features/chat/api/updateTypebotInSession.ts index 1289fb9f5..f199f4ce5 100644 --- a/apps/viewer/src/features/chat/api/updateTypebotInSession.ts +++ b/apps/viewer/src/features/chat/api/updateTypebotInSession.ts @@ -3,7 +3,12 @@ import { TRPCError } from '@trpc/server' import { z } from 'zod' import { getSession } from '../queries/getSession' import prisma from '@/lib/prisma' -import { PublicTypebot, SessionState, Typebot } from '@typebot.io/schemas' +import { + PublicTypebot, + SessionState, + Typebot, + Variable, +} from '@typebot.io/schemas' export const updateTypebotInSession = publicProcedure .meta({ @@ -32,7 +37,7 @@ export const updateTypebotInSession = publicProcedure const publicTypebot = (await prisma.publicTypebot.findFirst({ where: { typebot: { - id: session.state.typebot.id, + id: session.state.typebotsQueue[0].typebot.id, OR: [ { workspace: { @@ -74,21 +79,28 @@ const updateSessionState = ( newTypebot: Pick ): SessionState => ({ ...currentState, - typebot: { - ...currentState.typebot, - edges: newTypebot.edges, - variables: updateVariablesInSession( - currentState.typebot.variables, - newTypebot.variables - ), - groups: newTypebot.groups, - }, + typebotsQueue: currentState.typebotsQueue.map((typebotInQueue, index) => + index === 0 + ? { + ...typebotInQueue, + typebot: { + ...typebotInQueue.typebot, + edges: newTypebot.edges, + groups: newTypebot.groups, + variables: updateVariablesInSession( + typebotInQueue.typebot.variables, + newTypebot.variables + ), + }, + } + : typebotInQueue + ), }) const updateVariablesInSession = ( - currentVariables: SessionState['typebot']['variables'], + currentVariables: Variable[], newVariables: Typebot['variables'] -): SessionState['typebot']['variables'] => [ +): Variable[] => [ ...currentVariables, ...newVariables.filter( (newVariable) => diff --git a/apps/viewer/src/features/chat/helpers/addEdgeToTypebot.ts b/apps/viewer/src/features/chat/helpers/addEdgeToTypebot.ts index a83489ca5..83c12a519 100644 --- a/apps/viewer/src/features/chat/helpers/addEdgeToTypebot.ts +++ b/apps/viewer/src/features/chat/helpers/addEdgeToTypebot.ts @@ -6,10 +6,17 @@ export const addEdgeToTypebot = ( edge: Edge ): SessionState => ({ ...state, - typebot: { - ...state.typebot, - edges: [...state.typebot.edges, edge], - }, + typebotsQueue: state.typebotsQueue.map((typebot, index) => + index === 0 + ? { + ...typebot, + typebot: { + ...typebot.typebot, + edges: [...typebot.typebot.edges, edge], + }, + } + : typebot + ), }) export const createPortalEdge = ({ to }: Pick) => ({ diff --git a/apps/viewer/src/features/chat/helpers/continueBotFlow.ts b/apps/viewer/src/features/chat/helpers/continueBotFlow.ts index 6a439dfef..d607f472b 100644 --- a/apps/viewer/src/features/chat/helpers/continueBotFlow.ts +++ b/apps/viewer/src/features/chat/helpers/continueBotFlow.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server' import { + AnswerInSessionState, Block, BlockType, BubbleBlockType, @@ -8,7 +9,6 @@ import { InputBlockType, IntegrationBlockType, LogicBlockType, - ResultInSession, SessionState, SetVariableBlock, WebhookBlock, @@ -35,7 +35,7 @@ export const continueBotFlow = reply?: string ): Promise => { let newSessionState = { ...state } - const group = state.typebot.groups.find( + const group = state.typebotsQueue[0].typebot.groups.find( (group) => group.id === state.currentBlock?.groupId ) const blockIndex = @@ -52,7 +52,7 @@ export const continueBotFlow = }) if (block.type === LogicBlockType.SET_VARIABLE) { - const existingVariable = state.typebot.variables.find( + const existingVariable = state.typebotsQueue[0].typebot.variables.find( byId(block.options.variableId) ) if (existingVariable && reply) { @@ -103,7 +103,8 @@ export const continueBotFlow = formattedReply ) const itemId = nextEdgeId - ? state.typebot.edges.find(byId(nextEdgeId))?.from.itemId + ? newSessionState.typebotsQueue[0].typebot.edges.find(byId(nextEdgeId)) + ?.from.itemId : undefined newSessionState = await processAndSaveAnswer( state, @@ -128,7 +129,7 @@ export const continueBotFlow = } } - if (!nextEdgeId && state.linkedTypebots.queue.length === 0) + if (!nextEdgeId && state.typebotsQueue.length === 1) return { messages: [], newSessionState, @@ -138,7 +139,9 @@ export const continueBotFlow = const nextGroup = getNextGroup(newSessionState)(nextEdgeId) - if (!nextGroup) + newSessionState = nextGroup.newSessionState + + if (!nextGroup.group) return { messages: [], newSessionState, @@ -168,7 +171,7 @@ const saveVariableValueIfAny = (state: SessionState, block: InputBlock) => (reply: string): SessionState => { if (!block.options.variableId) return state - const foundVariable = state.typebot.variables.find( + const foundVariable = state.typebotsQueue[0].typebot.variables.find( (variable) => variable.id === block.options.variableId ) if (!foundVariable) return state @@ -235,34 +238,47 @@ const saveAnswer = itemId, }) + const key = block.options.variableId + ? state.typebotsQueue[0].typebot.variables.find( + (variable) => variable.id === block.options.variableId + )?.name + : state.typebotsQueue[0].typebot.groups.find((group) => + group.blocks.find((blockInGroup) => blockInGroup.id === block.id) + )?.title + return setNewAnswerInState(state)({ - blockId: block.id, - variableId: block.options.variableId ?? null, - content: reply, + key: key ?? block.id, + value: reply, }) } const setNewAnswerInState = - (state: SessionState) => (newAnswer: ResultInSession['answers'][number]) => { - const newAnswers = state.result.answers - .filter((answer) => answer.blockId !== newAnswer.blockId) + (state: SessionState) => (newAnswer: AnswerInSessionState) => { + const answers = state.typebotsQueue[0].answers + const newAnswers = answers + .filter((answer) => answer.key !== newAnswer.key) .concat(newAnswer) return { ...state, - result: { - ...state.result, - answers: newAnswers, - }, + typebotsQueue: state.typebotsQueue.map((typebot, index) => + index === 0 + ? { + ...typebot, + answers: newAnswers, + } + : typebot + ), } satisfies SessionState } const getOutgoingEdgeId = - ({ typebot: { variables } }: Pick) => + (state: Pick) => ( block: InputBlock | SetVariableBlock | OpenAIBlock | WebhookBlock, reply: string | undefined ) => { + const variables = state.typebotsQueue[0].typebot.variables if ( block.type === InputBlockType.CHOICE && !block.options.isMultipleChoice && diff --git a/apps/viewer/src/features/chat/helpers/executeGroup.ts b/apps/viewer/src/features/chat/helpers/executeGroup.ts index 45e8899f2..30e6e12e6 100644 --- a/apps/viewer/src/features/chat/helpers/executeGroup.ts +++ b/apps/viewer/src/features/chat/helpers/executeGroup.ts @@ -7,6 +7,7 @@ import { InputBlockType, RuntimeOptions, SessionState, + Variable, } from '@typebot.io/schemas' import { isBubbleBlock, @@ -47,7 +48,9 @@ export const executeGroup = if (isBubbleBlock(block)) { messages.push( - parseBubbleBlock(newSessionState.typebot.variables)(block) + parseBubbleBlock(newSessionState.typebotsQueue[0].typebot.variables)( + block + ) ) lastBubbleBlockId = block.id continue @@ -118,14 +121,14 @@ export const executeGroup = } } - if (!nextEdgeId) + if (!nextEdgeId && state.typebotsQueue.length === 1) return { messages, newSessionState, clientSideActions, logs } - const nextGroup = getNextGroup(newSessionState)(nextEdgeId) + const nextGroup = getNextGroup(newSessionState)(nextEdgeId ?? undefined) - if (nextGroup?.updatedContext) newSessionState = nextGroup.updatedContext + newSessionState = nextGroup.newSessionState - if (!nextGroup) { + if (!nextGroup.group) { return { messages, newSessionState, clientSideActions, logs } } @@ -141,7 +144,7 @@ export const executeGroup = } const computeRuntimeOptions = - (state: Pick) => + (state: SessionState) => (block: InputBlock): Promise | undefined => { switch (block.type) { case InputBlockType.PAYMENT: { @@ -151,7 +154,7 @@ const computeRuntimeOptions = } const getPrefilledInputValue = - (variables: SessionState['typebot']['variables']) => (block: InputBlock) => { + (variables: Variable[]) => (block: InputBlock) => { const variableValue = variables.find( (variable) => variable.id === block.options.variableId && isDefined(variable.value) @@ -161,7 +164,7 @@ const getPrefilledInputValue = } const parseBubbleBlock = - (variables: SessionState['typebot']['variables']) => + (variables: Variable[]) => (block: BubbleBlock): ChatReply['messages'][0] => { switch (block.type) { case BubbleBlockType.TEXT: @@ -197,15 +200,17 @@ const parseInput = } case InputBlockType.PICTURE_CHOICE: { return injectVariableValuesInPictureChoiceBlock( - state.typebot.variables + state.typebotsQueue[0].typebot.variables )(block) } case InputBlockType.NUMBER: { - const parsedBlock = deepParseVariables(state.typebot.variables)({ + const parsedBlock = deepParseVariables( + state.typebotsQueue[0].typebot.variables + )({ ...block, - prefilledValue: getPrefilledInputValue(state.typebot.variables)( - block - ), + prefilledValue: getPrefilledInputValue( + state.typebotsQueue[0].typebot.variables + )(block), }) return { ...parsedBlock, @@ -224,12 +229,12 @@ const parseInput = } } default: { - return deepParseVariables(state.typebot.variables)({ + return deepParseVariables(state.typebotsQueue[0].typebot.variables)({ ...block, runtimeOptions: await computeRuntimeOptions(state)(block), - prefilledValue: getPrefilledInputValue(state.typebot.variables)( - block - ), + prefilledValue: getPrefilledInputValue( + state.typebotsQueue[0].typebot.variables + )(block), }) } } diff --git a/apps/viewer/src/features/chat/helpers/getNextGroup.ts b/apps/viewer/src/features/chat/helpers/getNextGroup.ts index fd8747313..2b2cb4618 100644 --- a/apps/viewer/src/features/chat/helpers/getNextGroup.ts +++ b/apps/viewer/src/features/chat/helpers/getNextGroup.ts @@ -2,37 +2,81 @@ import { byId } from '@typebot.io/lib' import { Group, SessionState } from '@typebot.io/schemas' export type NextGroup = { - group: Group - updatedContext?: SessionState + group?: Group + newSessionState: SessionState } export const getNextGroup = (state: SessionState) => - (edgeId?: string): NextGroup | null => { - const { typebot } = state - const nextEdge = typebot.edges.find(byId(edgeId)) + (edgeId?: string): NextGroup => { + const nextEdge = state.typebotsQueue[0].typebot.edges.find(byId(edgeId)) if (!nextEdge) { - if (state.linkedTypebots.queue.length > 0) { - const nextEdgeId = state.linkedTypebots.queue[0].edgeId - const updatedContext = { + if (state.typebotsQueue.length > 1) { + const nextEdgeId = state.typebotsQueue[0].edgeIdToTriggerWhenDone + const isMergingWithParent = state.typebotsQueue[0].isMergingWithParent + const newSessionState = { ...state, - linkedBotQueue: state.linkedTypebots.queue.slice(1), - } - const nextGroup = getNextGroup(updatedContext)(nextEdgeId) - if (!nextGroup) return null + typebotsQueue: [ + { + ...state.typebotsQueue[1], + typebot: isMergingWithParent + ? { + ...state.typebotsQueue[1].typebot, + variables: state.typebotsQueue[1].typebot.variables.map( + (variable) => ({ + ...variable, + value: state.typebotsQueue[0].answers.find( + (answer) => answer.key === variable.name + )?.value, + }) + ), + } + : state.typebotsQueue[1].typebot, + answers: isMergingWithParent + ? [ + ...state.typebotsQueue[1].answers.filter( + (incomingAnswer) => + !state.typebotsQueue[0].answers.find( + (currentAnswer) => + currentAnswer.key === incomingAnswer.key + ) + ), + ...state.typebotsQueue[0].answers, + ] + : state.typebotsQueue[1].answers, + }, + ...state.typebotsQueue.slice(2), + ], + } satisfies SessionState + const nextGroup = getNextGroup(newSessionState)(nextEdgeId) + if (!nextGroup) + return { + newSessionState, + } return { ...nextGroup, - updatedContext, + newSessionState, } } - return null + return { + newSessionState: state, + } } - const nextGroup = typebot.groups.find(byId(nextEdge.to.groupId)) - if (!nextGroup) return null + const nextGroup = state.typebotsQueue[0].typebot.groups.find( + byId(nextEdge.to.groupId) + ) + if (!nextGroup) + return { + newSessionState: state, + } const startBlockIndex = nextEdge.to.blockId ? nextGroup.blocks.findIndex(byId(nextEdge.to.blockId)) : 0 return { - group: { ...nextGroup, blocks: nextGroup.blocks.slice(startBlockIndex) }, + group: { + ...nextGroup, + blocks: nextGroup.blocks.slice(startBlockIndex), + }, + newSessionState: state, } } diff --git a/apps/viewer/src/features/chat/helpers/saveStateToDatabase.ts b/apps/viewer/src/features/chat/helpers/saveStateToDatabase.ts index 4bb9c5913..fc38ed72a 100644 --- a/apps/viewer/src/features/chat/helpers/saveStateToDatabase.ts +++ b/apps/viewer/src/features/chat/helpers/saveStateToDatabase.ts @@ -11,6 +11,7 @@ type Props = { logs: ChatReply['logs'] clientSideActions: ChatReply['clientSideActions'] } + export const saveStateToDatabase = async ({ session: { state, id }, input, @@ -21,25 +22,30 @@ export const saveStateToDatabase = async ({ const session = id ? { state, id } : await createSession({ state }) - if (!state?.result?.id) return session + const resultId = state.typebotsQueue[0].resultId + + if (!resultId) return session const containsSetVariableClientSideAction = clientSideActions?.some( (action) => 'setVariable' in action ) + + const answers = state.typebotsQueue[0].answers + await upsertResult({ - state, + resultId, + typebot: state.typebotsQueue[0].typebot, isCompleted: Boolean( - !input && - !containsSetVariableClientSideAction && - state.result.answers.length > 0 + !input && !containsSetVariableClientSideAction && answers.length > 0 ), + hasStarted: answers.length > 0, }) if (logs && logs.length > 0) await saveLogs( logs.map((log) => ({ ...log, - resultId: state.result.id as string, + resultId, details: formatLogDetails(log.details), })) ) diff --git a/apps/viewer/src/features/chat/helpers/startBotFlow.ts b/apps/viewer/src/features/chat/helpers/startBotFlow.ts index be8c5fdb4..055b45ac7 100644 --- a/apps/viewer/src/features/chat/helpers/startBotFlow.ts +++ b/apps/viewer/src/features/chat/helpers/startBotFlow.ts @@ -8,7 +8,7 @@ export const startBotFlow = async ( startGroupId?: string ): Promise => { if (startGroupId) { - const group = state.typebot.groups.find( + const group = state.typebotsQueue[0].typebot.groups.find( (group) => group.id === startGroupId ) if (!group) @@ -18,9 +18,10 @@ export const startBotFlow = async ( }) return executeGroup(state)(group) } - const firstEdgeId = state.typebot.groups[0].blocks[0].outgoingEdgeId + const firstEdgeId = + state.typebotsQueue[0].typebot.groups[0].blocks[0].outgoingEdgeId if (!firstEdgeId) return { messages: [], newSessionState: state } const nextGroup = getNextGroup(state)(firstEdgeId) - if (!nextGroup) return { messages: [], newSessionState: state } + if (!nextGroup.group) return { messages: [], newSessionState: state } return executeGroup(state)(nextGroup.group) } diff --git a/apps/viewer/src/features/chat/queries/createResultIfNotExist.ts b/apps/viewer/src/features/chat/queries/createResultIfNotExist.ts new file mode 100644 index 000000000..ffc087b67 --- /dev/null +++ b/apps/viewer/src/features/chat/queries/createResultIfNotExist.ts @@ -0,0 +1,33 @@ +import prisma from '@/lib/prisma' +import { getDefinedVariables } from '@typebot.io/lib/results' +import { TypebotInSession } from '@typebot.io/schemas' + +type Props = { + resultId: string + typebot: TypebotInSession + hasStarted: boolean + isCompleted: boolean +} +export const createResultIfNotExist = async ({ + resultId, + typebot, + hasStarted, + isCompleted, +}: Props) => { + const existingResult = await prisma.result.findUnique({ + where: { id: resultId }, + select: { id: true }, + }) + if (existingResult) return + return prisma.result.createMany({ + data: [ + { + id: resultId, + typebotId: typebot.id, + isCompleted: isCompleted ? true : false, + hasStarted, + variables: getDefinedVariables(typebot.variables), + }, + ], + }) +} diff --git a/apps/viewer/src/features/chat/queries/findResult.ts b/apps/viewer/src/features/chat/queries/findResult.ts index d261d6358..b0ace7ed9 100644 --- a/apps/viewer/src/features/chat/queries/findResult.ts +++ b/apps/viewer/src/features/chat/queries/findResult.ts @@ -1,4 +1,5 @@ import prisma from '@/lib/prisma' +import { Answer, Result } from '@typebot.io/schemas' type Props = { id: string @@ -9,6 +10,18 @@ export const findResult = ({ id }: Props) => select: { id: true, variables: true, - answers: { select: { blockId: true, variableId: true, content: true } }, + hasStarted: true, + answers: { + select: { + content: true, + blockId: true, + variableId: true, + }, + }, }, - }) + }) as Promise< + | (Pick & { + answers: Pick[] + }) + | null + > diff --git a/apps/viewer/src/features/chat/queries/getSession.ts b/apps/viewer/src/features/chat/queries/getSession.ts index 2ac9d29c0..ef20425e6 100644 --- a/apps/viewer/src/features/chat/queries/getSession.ts +++ b/apps/viewer/src/features/chat/queries/getSession.ts @@ -1,12 +1,13 @@ import prisma from '@/lib/prisma' -import { ChatSession } from '@typebot.io/schemas' +import { ChatSession, sessionStateSchema } from '@typebot.io/schemas' export const getSession = async ( sessionId: string ): Promise | null> => { - const session = (await prisma.chatSession.findUnique({ + const session = await prisma.chatSession.findUnique({ where: { id: sessionId }, select: { id: true, state: true }, - })) as Pick | null - return session + }) + if (!session) return null + return { ...session, state: sessionStateSchema.parse(session.state) } } diff --git a/apps/viewer/src/features/chat/queries/upsertAnswer.ts b/apps/viewer/src/features/chat/queries/upsertAnswer.ts index 9b1ca7e18..76a5c8ba3 100644 --- a/apps/viewer/src/features/chat/queries/upsertAnswer.ts +++ b/apps/viewer/src/features/chat/queries/upsertAnswer.ts @@ -12,12 +12,13 @@ type Props = { state: SessionState } export const upsertAnswer = async ({ answer, reply, block, state }: Props) => { - if (!state.result?.id) return + const resultId = state.typebotsQueue[0].resultId + if (!resultId) return if (reply.includes('http') && block.type === InputBlockType.FILE) { answer.storageUsed = await computeStorageUsed(reply) } const where = { - resultId: state.result.id, + resultId, blockId: block.id, groupId: block.groupId, } @@ -37,7 +38,7 @@ export const upsertAnswer = async ({ answer, reply, block, state }: Props) => { }, }) return prisma.answer.createMany({ - data: [{ ...answer, resultId: state.result.id }], + data: [{ ...answer, resultId }], }) } diff --git a/apps/viewer/src/features/chat/queries/upsertResult.ts b/apps/viewer/src/features/chat/queries/upsertResult.ts index cdbc15d9c..88a713686 100644 --- a/apps/viewer/src/features/chat/queries/upsertResult.ts +++ b/apps/viewer/src/features/chat/queries/upsertResult.ts @@ -1,33 +1,43 @@ import prisma from '@/lib/prisma' -import { SessionState } from '@typebot.io/schemas' +import { getDefinedVariables } from '@typebot.io/lib/results' +import { TypebotInSession } from '@typebot.io/schemas' type Props = { - state: SessionState + resultId: string + typebot: TypebotInSession + hasStarted: boolean isCompleted: boolean } -export const upsertResult = async ({ state, isCompleted }: Props) => { +export const upsertResult = async ({ + resultId, + typebot, + hasStarted, + isCompleted, +}: Props) => { const existingResult = await prisma.result.findUnique({ - where: { id: state.result.id }, + where: { id: resultId }, select: { id: true }, }) + const variablesWithValue = getDefinedVariables(typebot.variables) + if (existingResult) { return prisma.result.updateMany({ - where: { id: state.result.id }, + where: { id: resultId }, data: { isCompleted: isCompleted ? true : undefined, - hasStarted: state.result.answers.length > 0 ? true : undefined, - variables: state.result.variables, + hasStarted, + variables: variablesWithValue, }, }) } return prisma.result.createMany({ data: [ { - id: state.result.id, - typebotId: state.typebot.id, + id: resultId, + typebotId: typebot.id, isCompleted: isCompleted ? true : false, - hasStarted: state.result.answers.length > 0 ? true : undefined, - variables: state.result.variables, + hasStarted, + variables: variablesWithValue, }, ], }) diff --git a/apps/viewer/src/features/variables/updateVariables.ts b/apps/viewer/src/features/variables/updateVariables.ts index 35050950d..6bfab1934 100644 --- a/apps/viewer/src/features/variables/updateVariables.ts +++ b/apps/viewer/src/features/variables/updateVariables.ts @@ -1,8 +1,6 @@ -import { isDefined } from '@typebot.io/lib' import { SessionState, VariableWithUnknowValue, - VariableWithValue, Variable, } from '@typebot.io/schemas' import { safeStringify } from '@typebot.io/lib/safeStringify' @@ -11,40 +9,23 @@ export const updateVariables = (state: SessionState) => (newVariables: VariableWithUnknowValue[]): SessionState => ({ ...state, - typebot: { - ...state.typebot, - variables: updateTypebotVariables(state)(newVariables), - }, - result: { - ...state.result, - variables: updateResultVariables(state)(newVariables), - }, + typebotsQueue: state.typebotsQueue.map((typebotInQueue, index) => + index === 0 + ? { + ...typebotInQueue, + typebot: { + ...typebotInQueue.typebot, + variables: updateTypebotVariables(typebotInQueue.typebot)( + newVariables + ), + }, + } + : typebotInQueue + ), }) -const updateResultVariables = - ({ result }: Pick) => - (newVariables: VariableWithUnknowValue[]): VariableWithValue[] => { - const serializedNewVariables = newVariables.map((variable) => ({ - ...variable, - value: Array.isArray(variable.value) - ? variable.value.map(safeStringify) - : safeStringify(variable.value), - })) - - const updatedVariables = [ - ...result.variables.filter((existingVariable) => - serializedNewVariables.every( - (newVariable) => existingVariable.id !== newVariable.id - ) - ), - ...serializedNewVariables, - ].filter((variable) => isDefined(variable.value)) as VariableWithValue[] - - return updatedVariables - } - const updateTypebotVariables = - ({ typebot }: Pick) => + (typebot: { variables: Variable[] }) => (newVariables: VariableWithUnknowValue[]): Variable[] => { const serializedNewVariables = newVariables.map((variable) => ({ ...variable, diff --git a/apps/viewer/src/pages/api/integrations/openai/streamer.ts b/apps/viewer/src/pages/api/integrations/openai/streamer.ts index b15a3d14d..c6bd98e08 100644 --- a/apps/viewer/src/pages/api/integrations/openai/streamer.ts +++ b/apps/viewer/src/pages/api/integrations/openai/streamer.ts @@ -41,7 +41,7 @@ const handler = async (req: Request) => { if (!state) return new Response('No state found', { status: 400 }) - const group = state.typebot.groups.find( + const group = state.typebotsQueue[0].typebot.groups.find( (group) => group.id === state.currentBlock?.groupId ) const blockIndex = diff --git a/apps/viewer/src/pages/api/typebots/[typebotId]/blocks/[blockId]/executeWebhook.ts b/apps/viewer/src/pages/api/typebots/[typebotId]/blocks/[blockId]/executeWebhook.ts index 6f9afbab4..0879edabd 100644 --- a/apps/viewer/src/pages/api/typebots/[typebotId]/blocks/[blockId]/executeWebhook.ts +++ b/apps/viewer/src/pages/api/typebots/[typebotId]/blocks/[blockId]/executeWebhook.ts @@ -85,131 +85,116 @@ const checkIfBodyIsAVariable = (body: string) => /^{{.+}}$/.test(body) export const executeWebhook = (typebot: Typebot) => - async ({ - webhook, - variables, - groupId, - resultValues, - resultId, - parentTypebotIds = [], - }: { - webhook: Webhook - variables: Variable[] - groupId: string - resultValues?: ResultValues - resultId?: string - parentTypebotIds: string[] - }): Promise => { - if (!webhook.url || !webhook.method) - return { - statusCode: 400, - data: { message: `Webhook doesn't have url or method` }, - } - const basicAuth: { username?: string; password?: string } = {} - const basicAuthHeaderIdx = webhook.headers.findIndex( - (h) => - h.key?.toLowerCase() === 'authorization' && - h.value?.toLowerCase()?.includes('basic') - ) - const isUsernamePasswordBasicAuth = - basicAuthHeaderIdx !== -1 && - webhook.headers[basicAuthHeaderIdx].value?.includes(':') - if (isUsernamePasswordBasicAuth) { - const [username, password] = - webhook.headers[basicAuthHeaderIdx].value?.slice(6).split(':') ?? [] - basicAuth.username = username - basicAuth.password = password - webhook.headers.splice(basicAuthHeaderIdx, 1) + async ({ + webhook, + variables, + groupId, + resultValues, + resultId, + parentTypebotIds = [], + }: { + webhook: Webhook + variables: Variable[] + groupId: string + resultValues?: ResultValues + resultId?: string + parentTypebotIds: string[] + }): Promise => { + if (!webhook.url || !webhook.method) + return { + statusCode: 400, + data: { message: `Webhook doesn't have url or method` }, } - const headers = convertKeyValueTableToObject(webhook.headers, variables) as - | Headers - | undefined - const queryParams = stringify( - convertKeyValueTableToObject(webhook.queryParams, variables) - ) - const contentType = headers ? headers['Content-Type'] : undefined - const linkedTypebotsParents = await fetchLinkedTypebots({ - isPreview: !('typebotId' in typebot), - typebotIds: parentTypebotIds, - }) - const linkedTypebotsChildren = await getPreviouslyLinkedTypebots({ - isPreview: !('typebotId' in typebot), - typebots: [typebot], - })([]) - const bodyContent = await getBodyContent(typebot, [ - ...linkedTypebotsParents, - ...linkedTypebotsChildren, - ])({ - body: webhook.body, - resultValues, - groupId, - variables, - }) - const { data: body, isJson } = - bodyContent && webhook.method !== HttpMethod.GET - ? safeJsonParse( + const basicAuth: { username?: string; password?: string } = {} + const basicAuthHeaderIdx = webhook.headers.findIndex( + (h) => + h.key?.toLowerCase() === 'authorization' && + h.value?.toLowerCase()?.includes('basic') + ) + const isUsernamePasswordBasicAuth = + basicAuthHeaderIdx !== -1 && + webhook.headers[basicAuthHeaderIdx].value?.includes(':') + if (isUsernamePasswordBasicAuth) { + const [username, password] = + webhook.headers[basicAuthHeaderIdx].value?.slice(6).split(':') ?? [] + basicAuth.username = username + basicAuth.password = password + webhook.headers.splice(basicAuthHeaderIdx, 1) + } + const headers = convertKeyValueTableToObject(webhook.headers, variables) as + | Headers + | undefined + const queryParams = stringify( + convertKeyValueTableToObject(webhook.queryParams, variables) + ) + const contentType = headers ? headers['Content-Type'] : undefined + const linkedTypebotsParents = await fetchLinkedTypebots({ + isPreview: !('typebotId' in typebot), + typebotIds: parentTypebotIds, + }) + const linkedTypebotsChildren = await getPreviouslyLinkedTypebots({ + isPreview: !('typebotId' in typebot), + typebots: [typebot], + })([]) + const bodyContent = await getBodyContent(typebot, [ + ...linkedTypebotsParents, + ...linkedTypebotsChildren, + ])({ + body: webhook.body, + resultValues, + groupId, + variables, + }) + const { data: body, isJson } = + bodyContent && webhook.method !== HttpMethod.GET + ? safeJsonParse( parseVariables(variables, { escapeForJson: !checkIfBodyIsAVariable(bodyContent), })(bodyContent) ) - : { data: undefined, isJson: false } + : { data: undefined, isJson: false } - const request = { - url: parseVariables(variables)( - webhook.url + (queryParams !== '' ? `?${queryParams}` : '') - ), - method: webhook.method as Method, - headers, - ...basicAuth, - json: - !contentType?.includes('x-www-form-urlencoded') && body && isJson - ? body - : undefined, - form: - contentType?.includes('x-www-form-urlencoded') && body - ? body - : undefined, - body: body && !isJson ? body : undefined, - } - try { - const response = await got(request.url, omit(request, 'url')) - await saveSuccessLog({ - resultId, - message: 'Webhook successfuly executed.', - details: { - statusCode: response.statusCode, - request, - response: safeJsonParse(response.body).data, - }, - }) - return { + const request = { + url: parseVariables(variables)( + webhook.url + (queryParams !== '' ? `?${queryParams}` : '') + ), + method: webhook.method as Method, + headers, + ...basicAuth, + json: + !contentType?.includes('x-www-form-urlencoded') && body && isJson + ? body + : undefined, + form: + contentType?.includes('x-www-form-urlencoded') && body + ? body + : undefined, + body: body && !isJson ? body : undefined, + } + try { + const response = await got(request.url, omit(request, 'url')) + await saveSuccessLog({ + resultId, + message: 'Webhook successfuly executed.', + details: { statusCode: response.statusCode, - data: safeJsonParse(response.body).data, - } - } catch (error) { - if (error instanceof HTTPError) { - const response = { - statusCode: error.response.statusCode, - data: safeJsonParse(error.response.body as string).data, - } - await saveErrorLog({ - resultId, - message: 'Webhook returned an error', - details: { - request, - response, - }, - }) - return response - } + request, + response: safeJsonParse(response.body).data, + }, + }) + return { + statusCode: response.statusCode, + data: safeJsonParse(response.body).data, + } + } catch (error) { + if (error instanceof HTTPError) { const response = { - statusCode: 500, - data: { message: `Error from Typebot server: ${error}` }, + statusCode: error.response.statusCode, + data: safeJsonParse(error.response.body as string).data, } - console.error(error) await saveErrorLog({ resultId, - message: 'Webhook failed to execute', + message: 'Webhook returned an error', details: { request, response, @@ -217,36 +202,66 @@ export const executeWebhook = }) return response } + const response = { + statusCode: 500, + data: { message: `Error from Typebot server: ${error}` }, + } + console.error(error) + await saveErrorLog({ + resultId, + message: 'Webhook failed to execute', + details: { + request, + response, + }, + }) + return response } + } const getBodyContent = ( typebot: Pick, linkedTypebots: (Typebot | PublicTypebot)[] ) => - async ({ - body, - resultValues, - groupId, - variables, - }: { - body?: string | null - resultValues?: ResultValues - groupId: string - variables: Variable[] - }): Promise => { - if (!body) return - return body === '{{state}}' - ? JSON.stringify( + async ({ + body, + resultValues, + groupId, + variables, + }: { + body?: string | null + resultValues?: ResultValues + groupId: string + variables: Variable[] + }): Promise => { + if (!body) return + return body === '{{state}}' + ? JSON.stringify( resultValues - ? parseAnswers(typebot, linkedTypebots)(resultValues) + ? parseAnswers({ + answers: resultValues.answers.map((answer) => ({ + key: + (answer.variableId + ? typebot.variables.find( + (variable) => variable.id === answer.variableId + )?.name + : typebot.groups.find((group) => + group.blocks.find( + (block) => block.id === answer.blockId + ) + )?.title) ?? '', + value: answer.content, + })), + variables: resultValues.variables, + }) : await parseSampleResult(typebot, linkedTypebots)( - groupId, - variables - ) + groupId, + variables + ) ) - : body - } + : body + } const convertKeyValueTableToObject = ( keyValues: KeyValue[] | undefined, diff --git a/apps/viewer/src/pages/api/typebots/[typebotId]/integrations/email.tsx b/apps/viewer/src/pages/api/typebots/[typebotId]/integrations/email.tsx index 56a7cc19e..8d3708927 100644 --- a/apps/viewer/src/pages/api/typebots/[typebotId]/integrations/email.tsx +++ b/apps/viewer/src/pages/api/typebots/[typebotId]/integrations/email.tsx @@ -15,7 +15,6 @@ import Mail from 'nodemailer/lib/mailer' import { DefaultBotNotificationEmail } from '@typebot.io/emails' import { render } from '@faire/mjml-react/utils/render' import prisma from '@/lib/prisma' -import { getPreviouslyLinkedTypebots } from '@/features/blocks/logic/typebotLink/getPreviouslyLinkedTypebots' import { saveErrorLog } from '@/features/logs/saveErrorLog' import { saveSuccessLog } from '@/features/logs/saveSuccessLog' @@ -197,10 +196,20 @@ const getEmailBody = async ({ where: { typebotId }, })) as unknown as PublicTypebot if (!typebot) return - const linkedTypebots = await getPreviouslyLinkedTypebots({ - typebots: [typebot], - })([]) - const answers = parseAnswers(typebot, linkedTypebots)(resultValues) + const answers = parseAnswers({ + answers: resultValues.answers.map((answer) => ({ + key: + (answer.variableId + ? typebot.variables.find( + (variable) => variable.id === answer.variableId + )?.name + : typebot.groups.find((group) => + group.blocks.find((block) => block.id === answer.blockId) + )?.title) ?? '', + value: answer.content, + })), + variables: resultValues.variables, + }) return { html: render( , - linkedTypebots: Pick[] | undefined - ) => - ({ - createdAt, - answers, - variables: resultVariables, - }: Omit & { createdAt?: Date | string }): { - [key: string]: string - } => { - const header = parseResultHeader(typebot, linkedTypebots) - return { - submittedAt: !createdAt - ? new Date().toISOString() - : typeof createdAt === 'string' - ? createdAt - : createdAt.toISOString(), - ...[...answers, ...resultVariables].reduce<{ - [key: string]: string - }>((o, answerOrVariable) => { - const isVariable = !('blockId' in answerOrVariable) - if (isVariable) { - const variable = answerOrVariable as VariableWithValue - if (variable.value === null) return o - return { ...o, [variable.name]: variable.value.toString() } - } - const answer = answerOrVariable as Answer - const key = answer.variableId - ? header.find( - (cell) => - answer.variableId && - cell.variableIds?.includes(answer.variableId) - )?.label - : header.find((cell) => - cell.blocks?.some((block) => block.id === answer.blockId) - )?.label - if (!key) return o - if (isDefined(o[key])) return o - return { - ...o, - [key]: answer.content.toString(), - } - }, {}), - } +export const parseAnswers = ({ + answers, + variables: resultVariables, +}: { + answers: AnswerInSessionState[] + variables: VariableWithValue[] +}): { + [key: string]: string +} => { + return { + submittedAt: new Date().toISOString(), + ...[...answers, ...resultVariables].reduce<{ + [key: string]: string + }>((o, answerOrVariable) => { + if ('id' in answerOrVariable) { + const variable = answerOrVariable + if (variable.value === null) return o + return { ...o, [variable.name]: variable.value.toString() } + } + const answer = answerOrVariable as AnswerInSessionState + if (isEmpty(answer.key)) return o + return { + ...o, + [answer.key]: answer.value, + } + }, {}), } +} + +export const getDefinedVariables = (variables: Variable[]) => + variables.filter((variable) => + isDefined(variable.value) + ) as VariableWithValue[] diff --git a/packages/schemas/features/blocks/logic/typebotLink.ts b/packages/schemas/features/blocks/logic/typebotLink.ts index cb600d038..842dda350 100644 --- a/packages/schemas/features/blocks/logic/typebotLink.ts +++ b/packages/schemas/features/blocks/logic/typebotLink.ts @@ -5,6 +5,7 @@ import { LogicBlockType } from './enums' export const typebotLinkOptionsSchema = z.object({ typebotId: z.string().optional(), groupId: z.string().optional(), + mergeResults: z.boolean().optional(), }) export const typebotLinkBlockSchema = blockBaseSchema.merge( @@ -14,7 +15,9 @@ export const typebotLinkBlockSchema = blockBaseSchema.merge( }) ) -export const defaultTypebotLinkOptions: TypebotLinkOptions = {} +export const defaultTypebotLinkOptions: TypebotLinkOptions = { + mergeResults: false, +} export type TypebotLinkBlock = z.infer export type TypebotLinkOptions = z.infer diff --git a/packages/schemas/features/chat.ts b/packages/schemas/features/chat/schema.ts similarity index 79% rename from packages/schemas/features/chat.ts rename to packages/schemas/features/chat/schema.ts index da27f8b52..c86afcaea 100644 --- a/packages/schemas/features/chat.ts +++ b/packages/schemas/features/chat/schema.ts @@ -5,68 +5,21 @@ import { paymentInputRuntimeOptionsSchema, pixelOptionsSchema, redirectOptionsSchema, -} from './blocks' -import { publicTypebotSchema } from './publicTypebot' -import { logSchema, resultSchema } from './result' -import { listVariableValue, typebotSchema } from './typebot' +} from '../blocks' +import { logSchema } from '../result' +import { listVariableValue, typebotSchema } from '../typebot' import { textBubbleContentSchema, imageBubbleContentSchema, videoBubbleContentSchema, audioBubbleContentSchema, embedBubbleContentSchema, -} from './blocks/bubbles' -import { answerSchema } from './answer' -import { BubbleBlockType } from './blocks/bubbles/enums' -import { inputBlockSchemas } from './blocks/schemas' -import { chatCompletionMessageSchema } from './blocks/integrations/openai' - -const typebotInSessionStateSchema = publicTypebotSchema._def.schema.pick({ - id: true, - groups: true, - edges: true, - variables: true, -}) - -const dynamicThemeSchema = z.object({ - hostAvatarUrl: z.string().optional(), - guestAvatarUrl: z.string().optional(), -}) - -const answerInSessionStateSchema = answerSchema.pick({ - content: true, - blockId: true, - variableId: true, -}) - -const resultInSessionStateSchema = resultSchema - .pick({ - variables: true, - }) - .merge( - z.object({ - answers: z.array(answerInSessionStateSchema), - id: z.string().optional(), - }) - ) - -export const sessionStateSchema = z.object({ - typebot: typebotInSessionStateSchema, - dynamicTheme: dynamicThemeSchema.optional(), - linkedTypebots: z.object({ - typebots: z.array(typebotInSessionStateSchema), - queue: z.array(z.object({ edgeId: z.string(), typebotId: z.string() })), - }), - currentTypebotId: z.string(), - result: resultInSessionStateSchema, - currentBlock: z - .object({ - blockId: z.string(), - groupId: z.string(), - }) - .optional(), - isStreamEnabled: z.boolean().optional(), -}) +} from '../blocks/bubbles' +import { BubbleBlockType } from '../blocks/bubbles/enums' +import { inputBlockSchemas } from '../blocks/schemas' +import { chatCompletionMessageSchema } from '../blocks/integrations/openai' +import { sessionStateSchema } from './sessionState' +import { dynamicThemeSchema } from './shared' const chatSessionSchema = z.object({ id: z.string(), @@ -301,9 +254,7 @@ export const chatReplySchema = z.object({ }) export type ChatSession = z.infer -export type SessionState = z.infer -export type TypebotInSession = z.infer -export type ResultInSession = z.infer + export type ChatReply = z.infer export type ChatMessage = z.infer export type SendMessageInput = z.infer diff --git a/packages/schemas/features/chat/sessionState.ts b/packages/schemas/features/chat/sessionState.ts new file mode 100644 index 000000000..f2c9d92f9 --- /dev/null +++ b/packages/schemas/features/chat/sessionState.ts @@ -0,0 +1,125 @@ +import { z } from 'zod' +import { answerSchema } from '../answer' +import { resultSchema } from '../result' +import { typebotInSessionStateSchema, dynamicThemeSchema } from './shared' + +const answerInSessionStateSchema = answerSchema.pick({ + content: true, + blockId: true, + variableId: true, +}) + +const answerInSessionStateSchemaV2 = z.object({ + key: z.string(), + value: z.string(), +}) + +export type AnswerInSessionState = z.infer + +const resultInSessionStateSchema = resultSchema + .pick({ + variables: true, + }) + .merge( + z.object({ + answers: z.array(answerInSessionStateSchema), + id: z.string().optional(), + }) + ) + +const sessionStateSchemaV1 = z.object({ + typebot: typebotInSessionStateSchema, + dynamicTheme: dynamicThemeSchema.optional(), + linkedTypebots: z.object({ + typebots: z.array(typebotInSessionStateSchema), + queue: z.array(z.object({ edgeId: z.string(), typebotId: z.string() })), + }), + currentTypebotId: z.string(), + result: resultInSessionStateSchema, + currentBlock: z + .object({ + blockId: z.string(), + groupId: z.string(), + }) + .optional(), + isStreamEnabled: z.boolean().optional(), +}) + +const sessionStateSchemaV2 = z.object({ + version: z.literal('2'), + typebotsQueue: z.array( + z.object({ + edgeIdToTriggerWhenDone: z.string().optional(), + isMergingWithParent: z.boolean().optional(), + resultId: z.string().optional(), + answers: z.array(answerInSessionStateSchemaV2), + typebot: typebotInSessionStateSchema, + }) + ), + dynamicTheme: dynamicThemeSchema.optional(), + currentBlock: z + .object({ + blockId: z.string(), + groupId: z.string(), + }) + .optional(), + isStreamEnabled: z.boolean().optional(), +}) + +export type SessionState = z.infer + +export const sessionStateSchema = sessionStateSchemaV1 + .or(sessionStateSchemaV2) + .transform((state): SessionState => { + if ('version' in state) return state + return { + version: '2', + typebotsQueue: [ + { + typebot: state.typebot, + resultId: state.result.id, + answers: state.result.answers.map((answer) => ({ + key: + (answer.variableId + ? state.typebot.variables.find( + (variable) => variable.id === answer.variableId + )?.name + : state.typebot.groups.find((group) => + group.blocks.find((block) => block.id === answer.blockId) + )?.title) ?? '', + value: answer.content, + })), + isMergingWithParent: true, + edgeIdToTriggerWhenDone: + state.linkedTypebots.queue.length > 0 + ? state.linkedTypebots.queue[0].edgeId + : undefined, + }, + ...state.linkedTypebots.typebots.map( + (typebot, index) => + ({ + typebot, + resultId: state.result.id, + answers: state.result.answers.map((answer) => ({ + key: + (answer.variableId + ? state.typebot.variables.find( + (variable) => variable.id === answer.variableId + )?.name + : state.typebot.groups.find((group) => + group.blocks.find( + (block) => block.id === answer.blockId + ) + )?.title) ?? '', + value: answer.content, + })), + edgeIdToTriggerWhenDone: state.linkedTypebots.queue.at(index + 1) + ?.edgeId, + } satisfies SessionState['typebotsQueue'][number]) + ), + ], + dynamicTheme: state.dynamicTheme, + currentBlock: state.currentBlock, + isStreamEnabled: state.isStreamEnabled, + } + }) diff --git a/packages/schemas/features/chat/shared.ts b/packages/schemas/features/chat/shared.ts new file mode 100644 index 000000000..c05813d06 --- /dev/null +++ b/packages/schemas/features/chat/shared.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' +import { publicTypebotSchema } from '../publicTypebot' + +export const typebotInSessionStateSchema = publicTypebotSchema._def.schema.pick( + { + id: true, + groups: true, + edges: true, + variables: true, + } +) +export type TypebotInSession = z.infer + +export const dynamicThemeSchema = z.object({ + hostAvatarUrl: z.string().optional(), + guestAvatarUrl: z.string().optional(), +}) diff --git a/packages/schemas/index.ts b/packages/schemas/index.ts index 4c298d0df..4811b0085 100644 --- a/packages/schemas/index.ts +++ b/packages/schemas/index.ts @@ -5,6 +5,8 @@ export * from './features/result' export * from './features/answer' export * from './features/utils' export * from './features/credentials' -export * from './features/chat' +export * from './features/chat/schema' +export * from './features/chat/sessionState' +export * from './features/chat/shared' export * from './features/workspace' export * from './features/items'