diff --git a/apps/builder/next.config.js b/apps/builder/next.config.js index 1ffc0f75b..126b2ad7c 100644 --- a/apps/builder/next.config.js +++ b/apps/builder/next.config.js @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ const { withSentryConfig } = require('@sentry/nextjs') const path = require('path') const withTM = require('next-transpile-modules')([ diff --git a/apps/builder/src/features/auth/api/getAuthenticatedUser.ts b/apps/builder/src/features/auth/api/getAuthenticatedUser.ts index 108cca7bc..91353ddd5 100644 --- a/apps/builder/src/features/auth/api/getAuthenticatedUser.ts +++ b/apps/builder/src/features/auth/api/getAuthenticatedUser.ts @@ -19,7 +19,6 @@ export const getAuthenticatedUser = async ( const authenticateByToken = async ( apiToken: string ): Promise => { - console.log(window) if (typeof window !== 'undefined') return return (await prisma.user.findFirst({ where: { apiTokens: { some: { token: apiToken } } }, diff --git a/apps/builder/src/features/dashboard/api/parseNewTypebot.ts b/apps/builder/src/features/dashboard/api/parseNewTypebot.ts index 944cf5574..17a743c8d 100644 --- a/apps/builder/src/features/dashboard/api/parseNewTypebot.ts +++ b/apps/builder/src/features/dashboard/api/parseNewTypebot.ts @@ -30,6 +30,7 @@ export const parseNewTypebot = ({ | 'icon' | 'isArchived' | 'isClosed' + | 'resultsTablePreferences' > => { const startGroupId = cuid() const startBlockId = cuid() diff --git a/apps/builder/src/features/dashboard/queries/importTypebotQuery.ts b/apps/builder/src/features/dashboard/queries/importTypebotQuery.ts index 78af32ac6..8a3d091d0 100644 --- a/apps/builder/src/features/dashboard/queries/importTypebotQuery.ts +++ b/apps/builder/src/features/dashboard/queries/importTypebotQuery.ts @@ -1,6 +1,6 @@ import { duplicateWebhookQueries } from '@/features/blocks/integrations/webhook' import cuid from 'cuid' -import { Plan } from 'db' +import { Plan, Prisma } from 'db' import { ChoiceInputBlock, ConditionBlock, @@ -38,7 +38,10 @@ export const importTypebotQuery = async (typebot: Typebot, userPlan: Plan) => { const duplicateTypebot = ( typebot: Typebot, userPlan: Plan -): { typebot: Typebot; webhookIdsMapping: Map } => { +): { + typebot: Omit & { id: string } + webhookIdsMapping: Map +} => { const groupIdsMapping = generateOldNewIdsMapping(typebot.groups) const edgeIdsMapping = generateOldNewIdsMapping(typebot.edges) const webhookIdsMapping = generateOldNewIdsMapping( @@ -119,8 +122,8 @@ const duplicateTypebot = ( general: { ...typebot.settings.general, isBrandingEnabled: true }, } : typebot.settings, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: new Date(), + updatedAt: new Date(), resultsTablePreferences: typebot.resultsTablePreferences ?? undefined, }, webhookIdsMapping, diff --git a/apps/builder/src/features/editor/hooks/useUndo.ts b/apps/builder/src/features/editor/hooks/useUndo.ts index 816d1eba1..df2024ba4 100644 --- a/apps/builder/src/features/editor/hooks/useUndo.ts +++ b/apps/builder/src/features/editor/hooks/useUndo.ts @@ -11,7 +11,7 @@ enum ActionType { Flush = 'FLUSH', } -export interface Actions { +export interface Actions { set: ( newPresent: T | ((current: T) => T), options?: { updateDate: boolean } @@ -24,13 +24,13 @@ export interface Actions { presentRef: React.MutableRefObject } -interface Action { +interface Action { type: ActionType newPresent?: T updateDate?: boolean } -export interface State { +export interface State { past: T[] present: T future: T[] @@ -42,7 +42,7 @@ const initialState = { future: [], } -const reducer = ( +const reducer = ( state: State, action: Action ) => { @@ -112,7 +112,7 @@ const reducer = ( } } -const useUndo = ( +const useUndo = ( initialPresent: T ): [State, Actions] => { const [state, dispatch] = useReducer(reducer, { diff --git a/apps/builder/src/features/publish/utils.ts b/apps/builder/src/features/publish/utils.ts index 3633361f4..e96670a09 100644 --- a/apps/builder/src/features/publish/utils.ts +++ b/apps/builder/src/features/publish/utils.ts @@ -26,6 +26,7 @@ export const parsePublicTypebotToTypebot = ( workspaceId: existingTypebot.workspaceId, isArchived: existingTypebot.isArchived, isClosed: existingTypebot.isClosed, + resultsTablePreferences: existingTypebot.resultsTablePreferences, }) export const parseTypebotToPublicTypebot = ( @@ -38,8 +39,8 @@ export const parseTypebotToPublicTypebot = ( settings: typebot.settings, theme: typebot.theme, variables: typebot.variables, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), + createdAt: new Date(), + updatedAt: new Date(), }) export const checkIfTypebotsAreEqual = (typebotA: Typebot, typebotB: Typebot) => diff --git a/apps/builder/src/features/results/components/ResultsTableContainer.tsx b/apps/builder/src/features/results/components/ResultsTableContainer.tsx index 2b01e01d3..d8075de42 100644 --- a/apps/builder/src/features/results/components/ResultsTableContainer.tsx +++ b/apps/builder/src/features/results/components/ResultsTableContainer.tsx @@ -57,7 +57,7 @@ export const ResultsTableContainer = () => { {typebot && ( { data: 'groups' in data ? data - : (parseNewTypebot({ + : parseNewTypebot({ ownerAvatarUrl: user.image, isBrandingEnabled: workspace.plan === Plan.FREE, ...data, - }) as Prisma.TypebotUncheckedCreateInput), + }), }) return res.send(typebot) } diff --git a/apps/builder/src/pages/api/v1/[...trpc].ts b/apps/builder/src/pages/api/v1/[...trpc].ts index f05f024e5..0a80412fb 100644 --- a/apps/builder/src/pages/api/v1/[...trpc].ts +++ b/apps/builder/src/pages/api/v1/[...trpc].ts @@ -1,8 +1,15 @@ import { createContext } from '@/utils/server/context' import { appRouter } from '@/utils/server/routers/v1/_app' +import { captureException } from '@sentry/nextjs' import { createOpenApiNextHandler } from 'trpc-openapi' export default createOpenApiNextHandler({ router: appRouter, createContext, + onError({ error }) { + if (error.code === 'INTERNAL_SERVER_ERROR') { + captureException(error) + console.error('Something went wrong', error) + } + }, }) diff --git a/apps/builder/src/utils/server/generateOpenApi.ts b/apps/builder/src/utils/server/generateOpenApi.ts index 29c9f16dd..caf377045 100644 --- a/apps/builder/src/utils/server/generateOpenApi.ts +++ b/apps/builder/src/utils/server/generateOpenApi.ts @@ -10,6 +10,6 @@ const openApiDocument = generateOpenApiDocument(appRouter, { }) writeFileSync( - './openapi/builder.json', + './openapi/builder/_spec_.json', JSON.stringify(openApiDocument, null, 2) ) diff --git a/apps/docs/docusaurus.config.js b/apps/docs/docusaurus.config.js index e398d23ca..c49703254 100644 --- a/apps/docs/docusaurus.config.js +++ b/apps/docs/docusaurus.config.js @@ -88,8 +88,8 @@ module.exports = { }, presets: [ [ - 'docusaurus-preset-openapi', - /** @type {import('docusaurus-preset-openapi').Options} */ + '@typebot.io/docusaurus-preset-openapi', + /** @type {import('@typebot.io/docusaurus-preset-openapi').Options} */ { api: { path: 'openapi', diff --git a/apps/docs/openapi/builder/_category_.json b/apps/docs/openapi/builder/_category_.json new file mode 100644 index 000000000..9f06d732a --- /dev/null +++ b/apps/docs/openapi/builder/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Builder", + "sidebar_position": 2 +} diff --git a/apps/docs/openapi/builder.json b/apps/docs/openapi/builder/_spec_.json similarity index 100% rename from apps/docs/openapi/builder.json rename to apps/docs/openapi/builder/_spec_.json diff --git a/apps/docs/openapi/authenticate.md b/apps/docs/openapi/builder/authenticate.md similarity index 99% rename from apps/docs/openapi/authenticate.md rename to apps/docs/openapi/builder/authenticate.md index aa00cbddf..18f5a9472 100644 --- a/apps/docs/openapi/authenticate.md +++ b/apps/docs/openapi/builder/authenticate.md @@ -1,6 +1,5 @@ --- sidebar_position: 1 -slug: / --- # Authentication diff --git a/apps/docs/openapi/chat/_category_.json b/apps/docs/openapi/chat/_category_.json new file mode 100644 index 000000000..056b4c0f7 --- /dev/null +++ b/apps/docs/openapi/chat/_category_.json @@ -0,0 +1,3 @@ +{ + "label": "Chat (Experimental ๐Ÿงช)" +} diff --git a/apps/docs/openapi/chat/_spec_.json b/apps/docs/openapi/chat/_spec_.json new file mode 100644 index 000000000..5c83f7ed8 --- /dev/null +++ b/apps/docs/openapi/chat/_spec_.json @@ -0,0 +1,1696 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Chat API", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://typebot.io/api/v1" + } + ], + "paths": { + "/typebots/{typebotId}/sendMessage": { + "post": { + "operationId": "query.chat.sendMessage", + "summary": "Send a message", + "description": "To initiate a chat, don't provide a `sessionId` and enter any `message`.\n\nContinue the conversation by providing the `sessionId` and the `message` that should answer the previous question.\n\nSet the `isPreview` option to `true` to chat with the non-published version of the typebot.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The answer to the previous question" + }, + "sessionId": { + "type": "string", + "description": "Session ID that you get from the initial chat request to a bot" + }, + "isPreview": { + "type": "boolean" + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + } + } + }, + "parameters": [ + { + "name": "typebotId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "[How can I find my typebot ID?](https://docs.typebot.io/api#how-to-find-my-typebotid)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "image", + "video", + "embed", + "audio" + ] + }, + "content": { + "anyOf": [ + { + "anyOf": [ + { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "plainText": { + "type": "string" + }, + "html": { + "type": "string" + } + }, + "required": [ + "plainText", + "html" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "url", + "youtube", + "vimeo" + ] + } + }, + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "height": { + "type": "number" + } + }, + "required": [ + "height" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + }, + "required": [ + "type", + "content" + ], + "additionalProperties": false + } + }, + "input": { + "anyOf": [ + { + "anyOf": [ + { + "anyOf": [ + { + "anyOf": [ + { + "anyOf": [ + { + "anyOf": [ + { + "anyOf": [ + { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "outgoingEdgeId": { + "type": "string" + } + }, + "required": [ + "id", + "groupId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text input" + ] + }, + "options": { + "allOf": [ + { + "allOf": [ + { + "type": "object", + "properties": { + "labels": { + "type": "object", + "properties": { + "placeholder": { + "type": "string" + }, + "button": { + "type": "string" + } + }, + "required": [ + "placeholder", + "button" + ], + "additionalProperties": false + } + }, + "required": [ + "labels" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "variableId": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "isLong": { + "type": "boolean" + } + }, + "required": [ + "isLong" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "type", + "options" + ], + "additionalProperties": false + } + ] + }, + { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "outgoingEdgeId": { + "type": "string" + } + }, + "required": [ + "id", + "groupId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "number input" + ] + }, + "options": { + "allOf": [ + { + "allOf": [ + { + "type": "object", + "properties": { + "variableId": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "labels": { + "type": "object", + "properties": { + "placeholder": { + "type": "string" + }, + "button": { + "type": "string" + } + }, + "required": [ + "placeholder", + "button" + ], + "additionalProperties": false + } + }, + "required": [ + "labels" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "step": { + "type": "number" + } + }, + "additionalProperties": false + } + ] + } + }, + "required": [ + "type", + "options" + ], + "additionalProperties": false + } + ] + } + ] + }, + { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "outgoingEdgeId": { + "type": "string" + } + }, + "required": [ + "id", + "groupId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "email input" + ] + }, + "options": { + "allOf": [ + { + "allOf": [ + { + "type": "object", + "properties": { + "variableId": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "labels": { + "type": "object", + "properties": { + "placeholder": { + "type": "string" + }, + "button": { + "type": "string" + } + }, + "required": [ + "placeholder", + "button" + ], + "additionalProperties": false + } + }, + "required": [ + "labels" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "retryMessageContent": { + "type": "string" + } + }, + "required": [ + "retryMessageContent" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "type", + "options" + ], + "additionalProperties": false + } + ] + } + ] + }, + { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "outgoingEdgeId": { + "type": "string" + } + }, + "required": [ + "id", + "groupId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "url input" + ] + }, + "options": { + "allOf": [ + { + "allOf": [ + { + "type": "object", + "properties": { + "variableId": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "labels": { + "type": "object", + "properties": { + "placeholder": { + "type": "string" + }, + "button": { + "type": "string" + } + }, + "required": [ + "placeholder", + "button" + ], + "additionalProperties": false + } + }, + "required": [ + "labels" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "retryMessageContent": { + "type": "string" + } + }, + "required": [ + "retryMessageContent" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "type", + "options" + ], + "additionalProperties": false + } + ] + } + ] + }, + { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "outgoingEdgeId": { + "type": "string" + } + }, + "required": [ + "id", + "groupId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "date input" + ] + }, + "options": { + "allOf": [ + { + "type": "object", + "properties": { + "variableId": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "labels": { + "type": "object", + "properties": { + "button": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "button", + "from", + "to" + ], + "additionalProperties": false + }, + "hasTime": { + "type": "boolean" + }, + "isRange": { + "type": "boolean" + } + }, + "required": [ + "labels", + "hasTime", + "isRange" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "type", + "options" + ], + "additionalProperties": false + } + ] + } + ] + }, + { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "outgoingEdgeId": { + "type": "string" + } + }, + "required": [ + "id", + "groupId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "phone number input" + ] + }, + "options": { + "allOf": [ + { + "allOf": [ + { + "type": "object", + "properties": { + "variableId": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "labels": { + "type": "object", + "properties": { + "placeholder": { + "type": "string" + }, + "button": { + "type": "string" + } + }, + "required": [ + "placeholder", + "button" + ], + "additionalProperties": false + } + }, + "required": [ + "labels" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "retryMessageContent": { + "type": "string" + }, + "defaultCountryCode": { + "type": "string" + } + }, + "required": [ + "retryMessageContent" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "type", + "options" + ], + "additionalProperties": false + } + ] + } + ] + }, + { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "outgoingEdgeId": { + "type": "string" + } + }, + "required": [ + "id", + "groupId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "choice input" + ] + }, + "items": { + "type": "array", + "items": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "blockId": { + "type": "string" + }, + "outgoingEdgeId": { + "type": "string" + } + }, + "required": [ + "id", + "blockId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "number", + "enum": [ + 0 + ] + }, + "content": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + } + }, + "options": { + "allOf": [ + { + "type": "object", + "properties": { + "variableId": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "isMultipleChoice": { + "type": "boolean" + }, + "buttonLabel": { + "type": "string" + } + }, + "required": [ + "isMultipleChoice", + "buttonLabel" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "type", + "items", + "options" + ], + "additionalProperties": false + } + ] + } + ] + }, + { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "outgoingEdgeId": { + "type": "string" + } + }, + "required": [ + "id", + "groupId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "payment input" + ] + }, + "options": { + "allOf": [ + { + "type": "object", + "properties": { + "variableId": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "Stripe" + ] + }, + "labels": { + "type": "object", + "properties": { + "button": { + "type": "string" + }, + "success": { + "type": "string" + } + }, + "required": [ + "button" + ], + "additionalProperties": false + }, + "additionalInformation": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phoneNumber": { + "type": "string" + } + }, + "additionalProperties": false + }, + "credentialsId": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "amount": { + "type": "string" + } + }, + "required": [ + "provider", + "labels", + "currency" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "type", + "options" + ], + "additionalProperties": false + } + ] + } + ] + }, + { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "outgoingEdgeId": { + "type": "string" + } + }, + "required": [ + "id", + "groupId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "rating input" + ] + }, + "options": { + "allOf": [ + { + "type": "object", + "properties": { + "variableId": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "buttonType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "Icons" + ] + }, + { + "type": "string", + "enum": [ + "Numbers" + ] + } + ] + }, + "length": { + "type": "number" + }, + "labels": { + "type": "object", + "properties": { + "left": { + "type": "string" + }, + "right": { + "type": "string" + }, + "button": { + "type": "string" + } + }, + "required": [ + "button" + ], + "additionalProperties": false + }, + "customIcon": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "svg": { + "type": "string" + } + }, + "required": [ + "isEnabled" + ], + "additionalProperties": false + } + }, + "required": [ + "buttonType", + "length", + "labels", + "customIcon" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "type", + "options" + ], + "additionalProperties": false + } + ] + } + ] + }, + { + "allOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "outgoingEdgeId": { + "type": "string" + } + }, + "required": [ + "id", + "groupId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file input" + ] + }, + "options": { + "allOf": [ + { + "type": "object", + "properties": { + "variableId": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "isRequired": { + "type": "boolean" + }, + "isMultipleAllowed": { + "type": "boolean" + }, + "labels": { + "type": "object", + "properties": { + "placeholder": { + "type": "string" + }, + "button": { + "type": "string" + } + }, + "required": [ + "placeholder", + "button" + ], + "additionalProperties": false + }, + "sizeLimit": { + "type": "number" + } + }, + "required": [ + "isMultipleAllowed", + "labels" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "type", + "options" + ], + "additionalProperties": false + } + ] + } + ] + }, + "logic": { + "type": "object", + "properties": { + "redirectUrl": { + "type": "string" + }, + "codeToExecute": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "not": {} + }, + { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "boolean" + } + ] + } + ], + "nullable": true + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + } + }, + "required": [ + "content", + "args" + ], + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "integrations": { + "type": "object", + "properties": { + "chatwoot": { + "type": "object", + "properties": { + "codeToExecute": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "not": {} + }, + { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + { + "type": "boolean" + } + ] + } + ], + "nullable": true + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + } + }, + "required": [ + "content", + "args" + ], + "additionalProperties": false + } + }, + "required": [ + "codeToExecute" + ], + "additionalProperties": false + }, + "googleAnalytics": { + "type": "object", + "properties": { + "trackingId": { + "type": "string" + }, + "category": { + "type": "string" + }, + "action": { + "type": "string" + }, + "label": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "required": [ + "messages" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "sessionId": { + "anyOf": [ + { + "not": {} + }, + { + "type": "string" + } + ], + "nullable": true + }, + "typebot": { + "anyOf": [ + { + "not": {} + }, + { + "type": "object", + "properties": { + "theme": { + "type": "object", + "properties": { + "general": { + "type": "object", + "properties": { + "font": { + "type": "string" + }, + "background": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "Color", + "Image", + "None" + ] + }, + "content": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "font", + "background" + ], + "additionalProperties": false + }, + "chat": { + "type": "object", + "properties": { + "hostAvatar": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "required": [ + "isEnabled" + ], + "additionalProperties": false + }, + "guestAvatar": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "required": [ + "isEnabled" + ], + "additionalProperties": false + }, + "hostBubbles": { + "type": "object", + "properties": { + "backgroundColor": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "required": [ + "backgroundColor", + "color" + ], + "additionalProperties": false + }, + "guestBubbles": { + "type": "object", + "properties": { + "backgroundColor": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "required": [ + "backgroundColor", + "color" + ], + "additionalProperties": false + }, + "buttons": { + "type": "object", + "properties": { + "backgroundColor": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "required": [ + "backgroundColor", + "color" + ], + "additionalProperties": false + }, + "inputs": { + "allOf": [ + { + "type": "object", + "properties": { + "backgroundColor": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "required": [ + "backgroundColor", + "color" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "placeholderColor": { + "type": "string" + } + }, + "required": [ + "placeholderColor" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "hostBubbles", + "guestBubbles", + "buttons", + "inputs" + ], + "additionalProperties": false + }, + "customCss": { + "type": "string" + } + }, + "required": [ + "general", + "chat" + ], + "additionalProperties": false + }, + "settings": { + "type": "object", + "properties": { + "general": { + "type": "object", + "properties": { + "isBrandingEnabled": { + "type": "boolean" + }, + "isTypingEmulationEnabled": { + "type": "boolean" + }, + "isInputPrefillEnabled": { + "type": "boolean" + }, + "isHideQueryParamsEnabled": { + "type": "boolean" + }, + "isNewResultOnRefreshEnabled": { + "type": "boolean" + }, + "isResultSavingEnabled": { + "type": "boolean" + } + }, + "required": [ + "isBrandingEnabled" + ], + "additionalProperties": false + }, + "typingEmulation": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "speed": { + "type": "number" + }, + "maxDelay": { + "type": "number" + } + }, + "required": [ + "enabled", + "speed", + "maxDelay" + ], + "additionalProperties": false + }, + "metadata": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "imageUrl": { + "type": "string" + }, + "favIconUrl": { + "type": "string" + }, + "customHeadCode": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": [ + "general", + "typingEmulation", + "metadata" + ], + "additionalProperties": false + } + }, + "required": [ + "theme", + "settings" + ], + "additionalProperties": false + } + ], + "nullable": true + } + }, + "additionalProperties": false + } + ] + } + } + } + }, + "default": { + "$ref": "#/components/responses/error" + } + } + } + } + }, + "components": { + "securitySchemes": { + "Authorization": { + "type": "http", + "scheme": "bearer" + } + }, + "responses": { + "error": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "code": { + "type": "string" + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": false + } + } + }, + "required": [ + "message", + "code" + ], + "additionalProperties": false + } + } + } + } + } + }, + "externalDocs": { + "url": "https://docs.typebot.io/api" + } +} \ No newline at end of file diff --git a/apps/docs/openapi/introduction.md b/apps/docs/openapi/introduction.md new file mode 100644 index 000000000..8cea4bcdc --- /dev/null +++ b/apps/docs/openapi/introduction.md @@ -0,0 +1,28 @@ +--- +sidebar_position: 1 +slug: / +--- + +# Introduction + +Typebot currently offers 2 APIs: **Builder** and **Chat** + +## Builder + +The Builder API is about what you can edit on https://app.typebot.io (i.e. create typebots, insert blocks etc, get results...). It is currently under active development and new endpoints will be added incrementally. + +## Chat + +:::caution +You should not use it in production. This API is experimental at the moment and will be heavily modified with time. +::: + +The Chat API allows you to execute (chat) with a typebot. + +### How to find my `typebotId` + +Get typebot ID diff --git a/apps/docs/package.json b/apps/docs/package.json index 175b58a77..85174f2fc 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -13,21 +13,21 @@ "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "update-search": "docker run -it --rm --env-file=.env -e \"CONFIG=$(cat docsearch-scrapper-config.json | jq -r tostring)\" algolia/docsearch-scraper", - "api:generate": "tsx --tsconfig ../builder/tsconfig.json ../builder/src/utils/server/generateOpenApi.ts" + "api:generate": "tsx --tsconfig ../builder/tsconfig.json ../builder/src/utils/server/generateOpenApi.ts && tsx --tsconfig ../viewer/openapi.tsconfig.json ../viewer/src/utils/server/generateOpenApi.ts" }, "dependencies": { "@docusaurus/core": "2.2.0", "@docusaurus/preset-classic": "2.2.0", - "@docusaurus/theme-search-algolia": "2.2.0", "@docusaurus/theme-common": "2.2.0", - "docusaurus-preset-openapi": "^0.6.3", - "react": "17.0.2", - "react-dom": "17.0.2", + "@docusaurus/theme-search-algolia": "2.2.0", "@mdx-js/react": "1.6.22", "@svgr/webpack": "6.5.1", "clsx": "1.2.1", + "@typebot.io/docusaurus-preset-openapi": "0.6.5", "file-loader": "6.2.0", "prism-react-renderer": "1.3.5", + "react": "17.0.2", + "react-dom": "17.0.2", "url-loader": "4.1.1" }, "browserslist": { diff --git a/apps/docs/src/css/custom.css b/apps/docs/src/css/custom.css index e34cc574e..00afa2267 100644 --- a/apps/docs/src/css/custom.css +++ b/apps/docs/src/css/custom.css @@ -110,3 +110,22 @@ details { .theme-api-markdown table td { border: 0; } + +.admonition { + margin-bottom: 1rem; +} +.admonition-heading svg { + margin-right: 0.5rem; + fill: currentColor; + width: 24px; + height: 24px; +} + +.admonition-heading { + text-transform: capitalize; +} + +.admonition-heading h5 { + display: flex; + align-items: center; +} diff --git a/apps/docs/static/img/api/typebotId.png b/apps/docs/static/img/api/typebotId.png new file mode 100644 index 000000000..f8f385f42 Binary files /dev/null and b/apps/docs/static/img/api/typebotId.png differ diff --git a/apps/viewer/next.config.js b/apps/viewer/next.config.js index 1ffc0f75b..126b2ad7c 100644 --- a/apps/viewer/next.config.js +++ b/apps/viewer/next.config.js @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ const { withSentryConfig } = require('@sentry/nextjs') const path = require('path') const withTM = require('next-transpile-modules')([ diff --git a/apps/viewer/openapi.tsconfig.json b/apps/viewer/openapi.tsconfig.json new file mode 100644 index 000000000..cf648d558 --- /dev/null +++ b/apps/viewer/openapi.tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "tsconfig/nextjs.json", + "compilerOptions": { + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] +} diff --git a/apps/viewer/package.json b/apps/viewer/package.json index 268513012..19d2f2e68 100644 --- a/apps/viewer/package.json +++ b/apps/viewer/package.json @@ -14,7 +14,7 @@ "dependencies": { "@sentry/nextjs": "7.21.1", "aws-sdk": "2.1261.0", - "bot-engine": "*", + "bot-engine": "workspace:*", "cors": "2.8.5", "cuid": "2.1.8", "db": "workspace:*", @@ -26,7 +26,9 @@ "react": "18.2.0", "react-dom": "18.2.0", "sanitize-html": "2.7.3", - "stripe": "11.1.0" + "stripe": "11.1.0", + "trpc-openapi": "1.0.0-alpha.4", + "@trpc/server": "10.3.0" }, "devDependencies": { "@babel/preset-env": "7.20.2", @@ -51,6 +53,8 @@ "papaparse": "5.3.2", "tsconfig": "workspace:*", "typescript": "4.9.3", + "zod": "3.19.1", + "superjson": "^1.11.0", "utils": "workspace:*" } } diff --git a/apps/viewer/src/features/blocks/inputs/buttons/api/index.ts b/apps/viewer/src/features/blocks/inputs/buttons/api/index.ts new file mode 100644 index 000000000..9c56149ef --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/buttons/api/index.ts @@ -0,0 +1 @@ +export * from './utils' diff --git a/apps/viewer/src/features/blocks/inputs/buttons/api/utils/index.ts b/apps/viewer/src/features/blocks/inputs/buttons/api/utils/index.ts new file mode 100644 index 000000000..53d445d25 --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/buttons/api/utils/index.ts @@ -0,0 +1 @@ +export * from './validateButtonInput' diff --git a/apps/viewer/src/features/blocks/inputs/buttons/api/utils/validateButtonInput.ts b/apps/viewer/src/features/blocks/inputs/buttons/api/utils/validateButtonInput.ts new file mode 100644 index 000000000..c66e3e98c --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/buttons/api/utils/validateButtonInput.ts @@ -0,0 +1,6 @@ +import { ChoiceInputBlock } from 'models' + +export const validateButtonInput = ( + buttonBlock: ChoiceInputBlock, + input: string +) => buttonBlock.items.some((item) => item.content === input) diff --git a/apps/viewer/src/features/blocks/inputs/date/api/index.ts b/apps/viewer/src/features/blocks/inputs/date/api/index.ts new file mode 100644 index 000000000..9c56149ef --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/date/api/index.ts @@ -0,0 +1 @@ +export * from './utils' diff --git a/apps/viewer/src/features/blocks/inputs/date/api/utils/index.ts b/apps/viewer/src/features/blocks/inputs/date/api/utils/index.ts new file mode 100644 index 000000000..03c496897 --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/date/api/utils/index.ts @@ -0,0 +1 @@ +export * from './parseReadableDate' diff --git a/apps/viewer/src/features/blocks/inputs/date/api/utils/parseReadableDate.ts b/apps/viewer/src/features/blocks/inputs/date/api/utils/parseReadableDate.ts new file mode 100644 index 000000000..4ddc82015 --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/date/api/utils/parseReadableDate.ts @@ -0,0 +1,26 @@ +export const parseReadableDate = ({ + from, + to, + hasTime, + isRange, +}: { + from: string + to: string + hasTime?: boolean + isRange?: boolean +}) => { + const currentLocale = window.navigator.language + const formatOptions: Intl.DateTimeFormatOptions = { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: hasTime ? '2-digit' : undefined, + minute: hasTime ? '2-digit' : undefined, + } + const fromReadable = new Date(from).toLocaleString( + currentLocale, + formatOptions + ) + const toReadable = new Date(to).toLocaleString(currentLocale, formatOptions) + return `${fromReadable}${isRange ? ` to ${toReadable}` : ''}` +} diff --git a/apps/viewer/src/features/blocks/inputs/email/api/index.ts b/apps/viewer/src/features/blocks/inputs/email/api/index.ts new file mode 100644 index 000000000..9c56149ef --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/email/api/index.ts @@ -0,0 +1 @@ +export * from './utils' diff --git a/apps/viewer/src/features/blocks/inputs/email/api/utils/index.ts b/apps/viewer/src/features/blocks/inputs/email/api/utils/index.ts new file mode 100644 index 000000000..95e4487d0 --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/email/api/utils/index.ts @@ -0,0 +1 @@ +export * from './validateEmail' diff --git a/apps/viewer/src/features/blocks/inputs/email/api/utils/validateEmail.ts b/apps/viewer/src/features/blocks/inputs/email/api/utils/validateEmail.ts new file mode 100644 index 000000000..286460076 --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/email/api/utils/validateEmail.ts @@ -0,0 +1,4 @@ +const emailRegex = + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + +export const validateEmail = (email: string) => emailRegex.test(email) diff --git a/apps/viewer/src/features/fileUpload/fileUpload.spec.ts b/apps/viewer/src/features/blocks/inputs/fileUpload/fileUpload.spec.ts similarity index 100% rename from apps/viewer/src/features/fileUpload/fileUpload.spec.ts rename to apps/viewer/src/features/blocks/inputs/fileUpload/fileUpload.spec.ts diff --git a/apps/viewer/src/features/blocks/inputs/phone/api/index.ts b/apps/viewer/src/features/blocks/inputs/phone/api/index.ts new file mode 100644 index 000000000..f66b65401 --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/phone/api/index.ts @@ -0,0 +1 @@ +export { validatePhoneNumber } from './utils/validatePhoneNumber' diff --git a/apps/viewer/src/features/blocks/inputs/phone/api/utils/validatePhoneNumber.ts b/apps/viewer/src/features/blocks/inputs/phone/api/utils/validatePhoneNumber.ts new file mode 100644 index 000000000..b01b5ec41 --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/phone/api/utils/validatePhoneNumber.ts @@ -0,0 +1,4 @@ +const phoneRegex = /^\+?[0-9]{6,}$/ + +export const validatePhoneNumber = (phoneNumber: string) => + phoneRegex.test(phoneNumber) diff --git a/apps/viewer/src/features/blocks/inputs/url/api/index.ts b/apps/viewer/src/features/blocks/inputs/url/api/index.ts new file mode 100644 index 000000000..cb0513d30 --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/url/api/index.ts @@ -0,0 +1 @@ +export { validateUrl } from './utils/validateUrl' diff --git a/apps/viewer/src/features/blocks/inputs/url/api/utils/validateUrl.ts b/apps/viewer/src/features/blocks/inputs/url/api/utils/validateUrl.ts new file mode 100644 index 000000000..37112d380 --- /dev/null +++ b/apps/viewer/src/features/blocks/inputs/url/api/utils/validateUrl.ts @@ -0,0 +1,4 @@ +const urlRegex = + /^(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/ + +export const validateUrl = (url: string) => urlRegex.test(url) diff --git a/apps/viewer/src/features/blocks/integrations/chatwoot/api/index.ts b/apps/viewer/src/features/blocks/integrations/chatwoot/api/index.ts new file mode 100644 index 000000000..b201a45e8 --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/chatwoot/api/index.ts @@ -0,0 +1 @@ +export * from './utils/executeChatwootBlock' diff --git a/apps/viewer/src/features/blocks/integrations/chatwoot/api/utils/executeChatwootBlock.ts b/apps/viewer/src/features/blocks/integrations/chatwoot/api/utils/executeChatwootBlock.ts new file mode 100644 index 000000000..6fc00102a --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/chatwoot/api/utils/executeChatwootBlock.ts @@ -0,0 +1,75 @@ +import { ExecuteIntegrationResponse } from '@/features/chat' +import { + parseVariables, + parseCorrectValueType, + extractVariablesFromText, +} from '@/features/variables' +import { ChatwootBlock, ChatwootOptions, SessionState } from 'models' + +const parseSetUserCode = (user: ChatwootOptions['user']) => ` +window.$chatwoot.setUser("${user?.id ?? ''}", { + email: ${user?.email ? `"${user.email}"` : 'undefined'}, + name: ${user?.name ? `"${user.name}"` : 'undefined'}, + avatar_url: ${user?.avatarUrl ? `"${user.avatarUrl}"` : 'undefined'}, + phone_number: ${user?.phoneNumber ? `"${user.phoneNumber}"` : 'undefined'}, +}); + +` +const parseChatwootOpenCode = ({ + baseUrl, + websiteToken, + user, +}: ChatwootOptions) => ` +if (window.$chatwoot) { + if(${Boolean(user)}) { + ${parseSetUserCode(user)} + } + window.$chatwoot.toggle("open"); +} else { + (function (d, t) { + var BASE_URL = "${baseUrl}"; + var g = d.createElement(t), + s = d.getElementsByTagName(t)[0]; + g.src = BASE_URL + "/packs/js/sdk.js"; + g.defer = true; + g.async = true; + s.parentNode.insertBefore(g, s); + g.onload = function () { + window.chatwootSDK.run({ + websiteToken: "${websiteToken}", + baseUrl: BASE_URL, + }); + window.addEventListener("chatwoot:ready", function () { + if(${Boolean(user?.id || user?.email)}) { + ${parseSetUserCode(user)} + } + window.$chatwoot.toggle("open"); + }); + }; + })(document, "script"); +}` + +export const executeChatwootBlock = ( + { typebot: { variables } }: SessionState, + block: ChatwootBlock +): ExecuteIntegrationResponse => { + const chatwootCode = parseChatwootOpenCode(block.options) + return { + outgoingEdgeId: block.outgoingEdgeId, + integrations: { + chatwoot: { + codeToExecute: { + content: parseVariables(variables, { fieldToParse: 'id' })( + chatwootCode + ), + args: extractVariablesFromText(variables)(chatwootCode).map( + (variable) => ({ + id: variable.id, + value: parseCorrectValueType(variable.value), + }) + ), + }, + }, + }, + } +} diff --git a/apps/viewer/src/features/chatwoot/chatwoot.spec.ts b/apps/viewer/src/features/blocks/integrations/chatwoot/chatwoot.spec.ts similarity index 100% rename from apps/viewer/src/features/chatwoot/chatwoot.spec.ts rename to apps/viewer/src/features/blocks/integrations/chatwoot/chatwoot.spec.ts diff --git a/apps/viewer/src/features/blocks/integrations/googleAnalytics/api/index.ts b/apps/viewer/src/features/blocks/integrations/googleAnalytics/api/index.ts new file mode 100644 index 000000000..732ccefbe --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/googleAnalytics/api/index.ts @@ -0,0 +1 @@ +export { executeGoogleAnalyticsBlock } from './utils/executeGoogleAnalyticsBlock' diff --git a/apps/viewer/src/features/blocks/integrations/googleAnalytics/api/utils/executeGoogleAnalyticsBlock.ts b/apps/viewer/src/features/blocks/integrations/googleAnalytics/api/utils/executeGoogleAnalyticsBlock.ts new file mode 100644 index 000000000..75cedcb1f --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/googleAnalytics/api/utils/executeGoogleAnalyticsBlock.ts @@ -0,0 +1,13 @@ +import { ExecuteIntegrationResponse } from '@/features/chat' +import { parseVariablesInObject } from '@/features/variables' +import { GoogleAnalyticsBlock, SessionState } from 'models' + +export const executeGoogleAnalyticsBlock = ( + { typebot: { variables } }: SessionState, + block: GoogleAnalyticsBlock +): ExecuteIntegrationResponse => ({ + outgoingEdgeId: block.outgoingEdgeId, + integrations: { + googleAnalytics: parseVariablesInObject(block.options, variables), + }, +}) diff --git a/apps/viewer/src/features/blocks/integrations/googleSheets/api/index.ts b/apps/viewer/src/features/blocks/integrations/googleSheets/api/index.ts new file mode 100644 index 000000000..9c56149ef --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/googleSheets/api/index.ts @@ -0,0 +1 @@ +export * from './utils' diff --git a/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/executeGoogleSheetBlock.ts b/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/executeGoogleSheetBlock.ts new file mode 100644 index 000000000..25068db23 --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/executeGoogleSheetBlock.ts @@ -0,0 +1,30 @@ +import { ExecuteIntegrationResponse } from '@/features/chat' +import { GoogleSheetsBlock, GoogleSheetsAction, SessionState } from 'models' +import { getRow } from './getRow' +import { insertRow } from './insertRow' +import { updateRow } from './updateRow' + +export const executeGoogleSheetBlock = async ( + state: SessionState, + block: GoogleSheetsBlock +): Promise => { + if (!('action' in block.options)) + return { outgoingEdgeId: block.outgoingEdgeId } + switch (block.options.action) { + case GoogleSheetsAction.INSERT_ROW: + return insertRow(state, { + options: block.options, + outgoingEdgeId: block.outgoingEdgeId, + }) + case GoogleSheetsAction.UPDATE_ROW: + return updateRow(state, { + options: block.options, + outgoingEdgeId: block.outgoingEdgeId, + }) + case GoogleSheetsAction.GET: + return getRow(state, { + options: block.options, + outgoingEdgeId: block.outgoingEdgeId, + }) + } +} diff --git a/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/getRow.ts b/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/getRow.ts new file mode 100644 index 000000000..b340f9104 --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/getRow.ts @@ -0,0 +1,89 @@ +import { SessionState, GoogleSheetsGetOptions, VariableWithValue } from 'models' +import { saveErrorLog, saveSuccessLog } from '@/features/logs/api' +import { getAuthenticatedGoogleDoc } from './helpers' +import { parseVariables, updateVariables } from '@/features/variables' +import { isNotEmpty, byId } from 'utils' +import { ExecuteIntegrationResponse } from '@/features/chat' + +export const getRow = async ( + state: SessionState, + { + outgoingEdgeId, + options, + }: { outgoingEdgeId?: string; options: GoogleSheetsGetOptions } +): Promise => { + const { sheetId, cellsToExtract, referenceCell } = options + if (!cellsToExtract || !sheetId || !referenceCell) return { outgoingEdgeId } + + const variables = state.typebot.variables + const resultId = state.result.id + + const doc = await getAuthenticatedGoogleDoc({ + credentialsId: options.credentialsId, + spreadsheetId: options.spreadsheetId, + }) + + const parsedReferenceCell = { + column: referenceCell.column, + value: parseVariables(variables)(referenceCell.value), + } + + const extractingColumns = cellsToExtract + .map((cell) => cell.column) + .filter(isNotEmpty) + + try { + await doc.loadInfo() + const sheet = doc.sheetsById[sheetId] + const rows = await sheet.getRows() + const row = rows.find( + (row) => + row[parsedReferenceCell.column as string] === parsedReferenceCell.value + ) + if (!row) { + await saveErrorLog({ + resultId, + message: "Couldn't find reference cell", + }) + return { outgoingEdgeId } + } + const data: { [key: string]: string } = { + ...extractingColumns.reduce( + (obj, column) => ({ ...obj, [column]: row[column] }), + {} + ), + } + await saveSuccessLog({ + resultId, + message: 'Succesfully fetched spreadsheet data', + }) + + const newVariables = options.cellsToExtract.reduce( + (newVariables, cell) => { + const existingVariable = variables.find(byId(cell.variableId)) + const value = data[cell.column ?? ''] ?? null + if (!existingVariable) return newVariables + return [ + ...newVariables, + { + ...existingVariable, + value, + }, + ] + }, + [] + ) + const newSessionState = await updateVariables(state)(newVariables) + return { + outgoingEdgeId, + newSessionState, + } + } catch (err) { + await saveErrorLog({ + resultId, + message: "Couldn't fetch spreadsheet data", + details: err, + }) + } + return { outgoingEdgeId } +} diff --git a/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/helpers.ts b/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/helpers.ts new file mode 100644 index 000000000..1183d5832 --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/helpers.ts @@ -0,0 +1,40 @@ +import { parseVariables } from '@/features/variables' +import { getAuthenticatedGoogleClient } from '@/lib/google-sheets' +import { TRPCError } from '@trpc/server' +import { GoogleSpreadsheet } from 'google-spreadsheet' +import { Variable, Cell } from 'models' + +export const parseCellValues = + (variables: Variable[]) => + (cells: Cell[]): { [key: string]: string } => + cells.reduce((row, cell) => { + return !cell.column || !cell.value + ? row + : { + ...row, + [cell.column]: parseVariables(variables)(cell.value), + } + }, {}) + +export const getAuthenticatedGoogleDoc = async ({ + credentialsId, + spreadsheetId, +}: { + credentialsId?: string + spreadsheetId?: string +}) => { + if (!credentialsId) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Missing credentialsId or sheetId', + }) + const doc = new GoogleSpreadsheet(spreadsheetId) + const auth = await getAuthenticatedGoogleClient(credentialsId) + if (!auth) + throw new TRPCError({ + code: 'NOT_FOUND', + message: "Couldn't find credentials in database", + }) + doc.useOAuth2Client(auth) + return doc +} diff --git a/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/index.ts b/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/index.ts new file mode 100644 index 000000000..38fbec31d --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/index.ts @@ -0,0 +1 @@ +export * from './executeGoogleSheetBlock' diff --git a/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/insertRow.ts b/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/insertRow.ts new file mode 100644 index 000000000..786efd71e --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/insertRow.ts @@ -0,0 +1,38 @@ +import { SessionState, GoogleSheetsInsertRowOptions } from 'models' +import { saveErrorLog, saveSuccessLog } from '@/features/logs/api' +import { getAuthenticatedGoogleDoc, parseCellValues } from './helpers' +import { ExecuteIntegrationResponse } from '@/features/chat' + +export const insertRow = async ( + { result, typebot: { variables } }: SessionState, + { + outgoingEdgeId, + options, + }: { outgoingEdgeId?: string; options: GoogleSheetsInsertRowOptions } +): Promise => { + if (!options.cellsToInsert || !options.sheetId) return { outgoingEdgeId } + + const doc = await getAuthenticatedGoogleDoc({ + credentialsId: options.credentialsId, + spreadsheetId: options.spreadsheetId, + }) + + const parsedValues = parseCellValues(variables)(options.cellsToInsert) + + try { + await doc.loadInfo() + const sheet = doc.sheetsById[options.sheetId] + await sheet.addRow(parsedValues) + await saveSuccessLog({ + resultId: result.id, + message: 'Succesfully inserted row', + }) + } catch (err) { + await saveErrorLog({ + resultId: result.id, + message: "Couldn't fetch spreadsheet data", + details: err, + }) + } + return { outgoingEdgeId } +} diff --git a/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/updateRow.ts b/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/updateRow.ts new file mode 100644 index 000000000..caa9eba9a --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/googleSheets/api/utils/updateRow.ts @@ -0,0 +1,60 @@ +import { SessionState, GoogleSheetsUpdateRowOptions } from 'models' +import { saveErrorLog, saveSuccessLog } from '@/features/logs/api' +import { getAuthenticatedGoogleDoc, parseCellValues } from './helpers' +import { TRPCError } from '@trpc/server' +import { parseVariables } from '@/features/variables' +import { ExecuteIntegrationResponse } from '@/features/chat' + +export const updateRow = async ( + { result, typebot: { variables } }: SessionState, + { + outgoingEdgeId, + options, + }: { outgoingEdgeId?: string; options: GoogleSheetsUpdateRowOptions } +): Promise => { + const { sheetId, referenceCell } = options + if (!options.cellsToUpsert || !sheetId || !referenceCell) + return { outgoingEdgeId } + + const doc = await getAuthenticatedGoogleDoc({ + credentialsId: options.credentialsId, + spreadsheetId: options.spreadsheetId, + }) + + const parsedReferenceCell = { + column: referenceCell.column, + value: parseVariables(variables)(referenceCell.value), + } + const parsedValues = parseCellValues(variables)(options.cellsToUpsert) + + try { + await doc.loadInfo() + const sheet = doc.sheetsById[sheetId] + const rows = await sheet.getRows() + const updatingRowIndex = rows.findIndex( + (row) => + row[parsedReferenceCell.column as string] === parsedReferenceCell.value + ) + if (updatingRowIndex === -1) { + new TRPCError({ + code: 'NOT_FOUND', + message: "Couldn't find row to update", + }) + } + for (const key in parsedValues) { + rows[updatingRowIndex][key] = parsedValues[key] + } + await rows[updatingRowIndex].save() + await saveSuccessLog({ + resultId: result.id, + message: 'Succesfully updated row', + }) + } catch (err) { + await saveErrorLog({ + resultId: result.id, + message: "Couldn't fetch spreadsheet data", + details: err, + }) + } + return { outgoingEdgeId } +} diff --git a/apps/viewer/src/features/blocks/integrations/sendEmail/api/constants.ts b/apps/viewer/src/features/blocks/integrations/sendEmail/api/constants.ts new file mode 100644 index 000000000..8a6a40343 --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/sendEmail/api/constants.ts @@ -0,0 +1,14 @@ +export const defaultTransportOptions = { + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + secure: process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : false, + auth: { + user: process.env.SMTP_USERNAME, + pass: process.env.SMTP_PASSWORD, + }, +} + +export const defaultFrom = { + name: process.env.SMTP_FROM?.split(' <')[0].replace(/"/g, ''), + email: process.env.SMTP_FROM?.match(/<(.*)>/)?.pop(), +} diff --git a/apps/viewer/src/features/blocks/integrations/sendEmail/api/index.ts b/apps/viewer/src/features/blocks/integrations/sendEmail/api/index.ts new file mode 100644 index 000000000..036ff7c32 --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/sendEmail/api/index.ts @@ -0,0 +1 @@ +export { executeSendEmailBlock } from './utils/executeSendEmailBlock' diff --git a/apps/viewer/src/features/blocks/integrations/sendEmail/api/utils/executeSendEmailBlock.tsx b/apps/viewer/src/features/blocks/integrations/sendEmail/api/utils/executeSendEmailBlock.tsx new file mode 100644 index 000000000..3a8ca4b4e --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/sendEmail/api/utils/executeSendEmailBlock.tsx @@ -0,0 +1,217 @@ +import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api' +import { ExecuteIntegrationResponse } from '@/features/chat' +import { saveErrorLog, saveSuccessLog } from '@/features/logs/api' +import { parseVariables } from '@/features/variables' +import prisma from '@/lib/prisma' +import { render } from '@faire/mjml-react/dist/src/utils/render' +import { DefaultBotNotificationEmail } from 'emails' +import { + PublicTypebot, + ResultValues, + SendEmailBlock, + SendEmailOptions, + SessionState, + SmtpCredentialsData, +} from 'models' +import { createTransport } from 'nodemailer' +import Mail from 'nodemailer/lib/mailer' +import { byId, isEmpty, isNotDefined, omit, parseAnswers } from 'utils' +import { decrypt } from 'utils/api' +import { defaultFrom, defaultTransportOptions } from '../constants' + +export const executeSendEmailBlock = async ( + { result, typebot }: SessionState, + block: SendEmailBlock +): Promise => { + const { options } = block + const { variables } = typebot + await sendEmail({ + typebotId: typebot.id, + resultId: result.id, + credentialsId: options.credentialsId, + recipients: options.recipients.map(parseVariables(variables)), + subject: parseVariables(variables)(options.subject ?? ''), + body: parseVariables(variables)(options.body ?? ''), + cc: (options.cc ?? []).map(parseVariables(variables)), + bcc: (options.bcc ?? []).map(parseVariables(variables)), + replyTo: options.replyTo + ? parseVariables(variables)(options.replyTo) + : undefined, + fileUrls: + variables.find(byId(options.attachmentsVariableId))?.value ?? undefined, + isCustomBody: options.isCustomBody, + isBodyCode: options.isBodyCode, + }) + return { outgoingEdgeId: block.outgoingEdgeId } +} + +const sendEmail = async ({ + typebotId, + resultId, + credentialsId, + recipients, + body, + subject, + cc, + bcc, + replyTo, + isBodyCode, + isCustomBody, + fileUrls, +}: SendEmailOptions & { + typebotId: string + resultId: string + fileUrls?: string +}) => { + const { name: replyToName } = parseEmailRecipient(replyTo) + + const { host, port, isTlsEnabled, username, password, from } = + (await getEmailInfo(credentialsId)) ?? {} + if (!from) return + + const transportConfig = { + host, + port, + secure: isTlsEnabled ?? undefined, + auth: { + user: username, + pass: password, + }, + } + + const emailBody = await getEmailBody({ + body, + isCustomBody, + isBodyCode, + typebotId, + resultId, + }) + + if (!emailBody) { + await saveErrorLog({ + resultId, + message: 'Email not sent', + details: { + transportConfig, + recipients, + subject, + cc, + bcc, + replyTo, + emailBody, + }, + }) + } + const transporter = createTransport(transportConfig) + const fromName = isEmpty(replyToName) ? from.name : replyToName + const email: Mail.Options = { + from: fromName ? `"${fromName}" <${from.email}>` : from.email, + cc, + bcc, + to: recipients, + replyTo, + subject, + attachments: fileUrls?.split(', ').map((url) => ({ path: url })), + ...emailBody, + } + try { + const info = await transporter.sendMail(email) + await saveSuccessLog({ + resultId, + message: 'Email successfully sent', + details: { + transportConfig: { + ...transportConfig, + auth: { user: transportConfig.auth.user, pass: '******' }, + }, + email, + }, + }) + } catch (err) { + await saveErrorLog({ + resultId, + message: 'Email not sent', + details: { + transportConfig: { + ...transportConfig, + auth: { user: transportConfig.auth.user, pass: '******' }, + }, + email, + error: err, + }, + }) + } +} + +const getEmailInfo = async ( + credentialsId: string +): Promise => { + if (credentialsId === 'default') + return { + host: defaultTransportOptions.host, + port: defaultTransportOptions.port, + username: defaultTransportOptions.auth.user, + password: defaultTransportOptions.auth.pass, + isTlsEnabled: undefined, + from: defaultFrom, + } + const credentials = await prisma.credentials.findUnique({ + where: { id: credentialsId }, + }) + if (!credentials) return + return decrypt(credentials.data, credentials.iv) as SmtpCredentialsData +} + +const getEmailBody = async ({ + body, + isCustomBody, + isBodyCode, + typebotId, + resultId, +}: { + typebotId: string + resultId: string +} & Pick): Promise< + { html?: string; text?: string } | undefined +> => { + if (isCustomBody || (isNotDefined(isCustomBody) && !isEmpty(body))) + return { + html: isBodyCode ? body : undefined, + text: !isBodyCode ? body : undefined, + } + const typebot = (await prisma.publicTypebot.findUnique({ + where: { typebotId }, + })) as unknown as PublicTypebot + if (!typebot) return + const linkedTypebots = await getLinkedTypebots(typebot) + const resultValues = (await prisma.result.findUnique({ + where: { id: resultId }, + include: { answers: true }, + })) as ResultValues | null + if (!resultValues) return + const answers = parseAnswers(typebot, linkedTypebots)(resultValues) + return { + html: render( + + ).html, + } +} + +const parseEmailRecipient = ( + recipient?: string +): { email?: string; name?: string } => { + if (!recipient) return {} + if (recipient.includes('<')) { + const [name, email] = recipient.split('<') + return { + name: name.replace(/>/g, '').trim().replace(/"/g, ''), + email: email.replace('>', '').trim(), + } + } + return { + email: recipient, + } +} diff --git a/apps/viewer/src/features/sendEmail/sendEmail.spec.ts b/apps/viewer/src/features/blocks/integrations/sendEmail/sendEmail.spec.ts similarity index 96% rename from apps/viewer/src/features/sendEmail/sendEmail.spec.ts rename to apps/viewer/src/features/blocks/integrations/sendEmail/sendEmail.spec.ts index d4545bf17..36a4af52f 100644 --- a/apps/viewer/src/features/sendEmail/sendEmail.spec.ts +++ b/apps/viewer/src/features/blocks/integrations/sendEmail/sendEmail.spec.ts @@ -1,5 +1,5 @@ import test, { expect } from '@playwright/test' -import { createSmtpCredentials } from '../../test/utils/databaseActions' +import { createSmtpCredentials } from '../../../../test/utils/databaseActions' import cuid from 'cuid' import { SmtpCredentialsData } from 'models' import { importTypebotInDatabase } from 'utils/playwright/databaseActions' diff --git a/apps/viewer/src/features/blocks/integrations/webhook/api/index.ts b/apps/viewer/src/features/blocks/integrations/webhook/api/index.ts new file mode 100644 index 000000000..9c56149ef --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/webhook/api/index.ts @@ -0,0 +1 @@ +export * from './utils' diff --git a/apps/viewer/src/features/blocks/integrations/webhook/api/utils/executeWebhookBlock.ts b/apps/viewer/src/features/blocks/integrations/webhook/api/utils/executeWebhookBlock.ts new file mode 100644 index 000000000..62030c870 --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/webhook/api/utils/executeWebhookBlock.ts @@ -0,0 +1,276 @@ +import { ExecuteIntegrationResponse } from '@/features/chat' +import { saveErrorLog, saveSuccessLog } from '@/features/logs/api' +import { parseVariables, updateVariables } from '@/features/variables' +import prisma from '@/lib/prisma' +import { + WebhookBlock, + ZapierBlock, + MakeComBlock, + PabblyConnectBlock, + VariableWithUnknowValue, + SessionState, + Webhook, + Typebot, + Variable, + WebhookResponse, + WebhookOptions, + defaultWebhookAttributes, + HttpMethod, + ResultValues, + PublicTypebot, + KeyValue, +} from 'models' +import { stringify } from 'qs' +import { byId, omit, parseAnswers } from 'utils' +import got, { Method, Headers, HTTPError } from 'got' +import { getResultValues } from '@/features/results/api' +import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api' +import { parseSampleResult } from './parseSampleResult' + +export const executeWebhookBlock = async ( + state: SessionState, + block: WebhookBlock | ZapierBlock | MakeComBlock | PabblyConnectBlock +): Promise => { + const { typebot, result } = state + const webhook = (await prisma.webhook.findUnique({ + where: { id: block.webhookId }, + })) as Webhook | null + if (!webhook) { + await saveErrorLog({ + resultId: result.id, + message: `Couldn't find webhook`, + }) + return { outgoingEdgeId: block.outgoingEdgeId } + } + const preparedWebhook = prepareWebhookAttributes(webhook, block.options) + const resultValues = await getResultValues(result.id) + if (!resultValues) return { outgoingEdgeId: block.outgoingEdgeId } + const webhookResponse = await executeWebhook(typebot)( + preparedWebhook, + typebot.variables, + block.groupId, + resultValues, + result.id + ) + const status = webhookResponse.statusCode.toString() + const isError = status.startsWith('4') || status.startsWith('5') + + if (isError) { + await saveErrorLog({ + resultId: result.id, + message: `Webhook returned error: ${webhookResponse.data}`, + details: JSON.stringify(webhookResponse.data, null, 2).substring(0, 1000), + }) + } else { + await saveSuccessLog({ + resultId: result.id, + message: `Webhook returned success: ${webhookResponse.data}`, + details: JSON.stringify(webhookResponse.data, null, 2).substring(0, 1000), + }) + } + + const newVariables = block.options.responseVariableMapping.reduce< + VariableWithUnknowValue[] + >((newVariables, varMapping) => { + if (!varMapping?.bodyPath || !varMapping.variableId) return newVariables + const existingVariable = typebot.variables.find(byId(varMapping.variableId)) + if (!existingVariable) return newVariables + const func = Function( + 'data', + `return data.${parseVariables(typebot.variables)(varMapping?.bodyPath)}` + ) + try { + const value: unknown = func(webhookResponse) + return [...newVariables, { ...existingVariable, value }] + } catch (err) { + return newVariables + } + }, []) + if (newVariables.length > 0) { + const newSessionState = await updateVariables(state)(newVariables) + return { + outgoingEdgeId: block.outgoingEdgeId, + newSessionState, + } + } + + return { outgoingEdgeId: block.outgoingEdgeId } +} + +const prepareWebhookAttributes = ( + webhook: Webhook, + options: WebhookOptions +): Webhook => { + if (options.isAdvancedConfig === false) { + return { ...webhook, body: '{{state}}', ...defaultWebhookAttributes } + } else if (options.isCustomBody === false) { + return { ...webhook, body: '{{state}}' } + } + return webhook +} + +const checkIfBodyIsAVariable = (body: string) => /^{{.+}}$/.test(body) + +export const executeWebhook = + (typebot: SessionState['typebot']) => + async ( + webhook: Webhook, + variables: Variable[], + groupId: string, + resultValues: ResultValues, + resultId: 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) + } + const headers = convertKeyValueTableToObject(webhook.headers, variables) as + | Headers + | undefined + const queryParams = stringify( + convertKeyValueTableToObject(webhook.queryParams, variables) + ) + const contentType = headers ? headers['Content-Type'] : undefined + const linkedTypebots = await getLinkedTypebots(typebot) + + const bodyContent = await getBodyContent( + typebot, + linkedTypebots + )({ + body: webhook.body, + resultValues, + groupId, + }) + const { data: body, isJson } = + bodyContent && webhook.method !== HttpMethod.GET + ? safeJsonParse( + parseVariables(variables, { + escapeForJson: !checkIfBodyIsAVariable(bodyContent), + })(bodyContent) + ) + : { data: undefined, isJson: false } + + const request = { + url: parseVariables(variables)( + webhook.url + (queryParams !== '' ? `?${queryParams}` : '') + ), + method: webhook.method as Method, + headers, + ...basicAuth, + json: + contentType !== 'x-www-form-urlencoded' && body && isJson + ? body + : undefined, + form: contentType === '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 { + 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 + } + 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, + }: { + body?: string | null + resultValues?: ResultValues + groupId: string + }): Promise => { + if (!body) return + return body === '{{state}}' + ? JSON.stringify( + resultValues + ? parseAnswers(typebot, linkedTypebots)(resultValues) + : await parseSampleResult(typebot, linkedTypebots)(groupId) + ) + : body + } + +const convertKeyValueTableToObject = ( + keyValues: KeyValue[] | undefined, + variables: Variable[] +) => { + if (!keyValues) return + return keyValues.reduce((object, item) => { + if (!item.key) return {} + return { + ...object, + [item.key]: parseVariables(variables)(item.value ?? ''), + } + }, {}) +} + +const safeJsonParse = (json: string): { data: any; isJson: boolean } => { + try { + return { data: JSON.parse(json), isJson: true } + } catch (err) { + return { data: json, isJson: false } + } +} diff --git a/apps/viewer/src/features/blocks/integrations/webhook/api/utils/index.ts b/apps/viewer/src/features/blocks/integrations/webhook/api/utils/index.ts new file mode 100644 index 000000000..6dcb2cfe3 --- /dev/null +++ b/apps/viewer/src/features/blocks/integrations/webhook/api/utils/index.ts @@ -0,0 +1,2 @@ +export * from './executeWebhookBlock' +export * from './parseSampleResult' diff --git a/apps/viewer/src/features/webhook/api/parseSampleResult.ts b/apps/viewer/src/features/blocks/integrations/webhook/api/utils/parseSampleResult.ts similarity index 100% rename from apps/viewer/src/features/webhook/api/parseSampleResult.ts rename to apps/viewer/src/features/blocks/integrations/webhook/api/utils/parseSampleResult.ts diff --git a/apps/viewer/src/features/webhook/webhook.spec.ts b/apps/viewer/src/features/blocks/integrations/webhook/webhook.spec.ts similarity index 100% rename from apps/viewer/src/features/webhook/webhook.spec.ts rename to apps/viewer/src/features/blocks/integrations/webhook/webhook.spec.ts diff --git a/apps/viewer/src/features/blocks/logic/code/api/index.ts b/apps/viewer/src/features/blocks/logic/code/api/index.ts new file mode 100644 index 000000000..84e34754a --- /dev/null +++ b/apps/viewer/src/features/blocks/logic/code/api/index.ts @@ -0,0 +1 @@ +export { executeCode } from './utils/executeCode' diff --git a/apps/viewer/src/features/blocks/logic/code/api/utils/executeCode.ts b/apps/viewer/src/features/blocks/logic/code/api/utils/executeCode.ts new file mode 100644 index 000000000..da53a3edf --- /dev/null +++ b/apps/viewer/src/features/blocks/logic/code/api/utils/executeCode.ts @@ -0,0 +1,34 @@ +import { ExecuteLogicResponse } from '@/features/chat' +import { + parseVariables, + parseCorrectValueType, + extractVariablesFromText, +} from '@/features/variables' +import { CodeBlock, SessionState } from 'models' + +export const executeCode = ( + { typebot: { variables } }: SessionState, + block: CodeBlock +): ExecuteLogicResponse => { + if (!block.options.content) return { outgoingEdgeId: block.outgoingEdgeId } + + const content = parseVariables(variables, { fieldToParse: 'id' })( + block.options.content + ) + const args = extractVariablesFromText(variables)(block.options.content).map( + (variable) => ({ + id: variable.id, + value: parseCorrectValueType(variable.value), + }) + ) + + return { + outgoingEdgeId: block.outgoingEdgeId, + logic: { + codeToExecute: { + content, + args, + }, + }, + } +} diff --git a/apps/viewer/src/features/blocks/logic/condition/api/index.ts b/apps/viewer/src/features/blocks/logic/condition/api/index.ts new file mode 100644 index 000000000..5039c53db --- /dev/null +++ b/apps/viewer/src/features/blocks/logic/condition/api/index.ts @@ -0,0 +1 @@ +export { executeCondition } from './utils/executeCondition' diff --git a/apps/viewer/src/features/blocks/logic/condition/api/utils/executeCondition.ts b/apps/viewer/src/features/blocks/logic/condition/api/utils/executeCondition.ts new file mode 100644 index 000000000..7ba95cb99 --- /dev/null +++ b/apps/viewer/src/features/blocks/logic/condition/api/utils/executeCondition.ts @@ -0,0 +1,60 @@ +import { ExecuteLogicResponse } from '@/features/chat' +import { parseVariables } from '@/features/variables' +import { + Comparison, + ComparisonOperators, + ConditionBlock, + LogicalOperator, + SessionState, + Variable, +} from 'models' +import { isNotDefined, isDefined } from 'utils' + +export const executeCondition = ( + { typebot: { variables } }: SessionState, + block: ConditionBlock +): ExecuteLogicResponse => { + const passedCondition = block.items.find((item) => { + const { content } = item + const isConditionPassed = + content.logicalOperator === LogicalOperator.AND + ? content.comparisons.every(executeComparison(variables)) + : content.comparisons.some(executeComparison(variables)) + return isConditionPassed + }) + return { + outgoingEdgeId: passedCondition + ? passedCondition.outgoingEdgeId + : block.outgoingEdgeId, + } +} + +const executeComparison = + (variables: Variable[]) => (comparison: Comparison) => { + if (!comparison?.variableId) return false + const inputValue = ( + variables.find((v) => v.id === comparison.variableId)?.value ?? '' + ).trim() + const value = parseVariables(variables)(comparison.value).trim() + if (isNotDefined(value)) return false + switch (comparison.comparisonOperator) { + case ComparisonOperators.CONTAINS: { + return inputValue.toLowerCase().includes(value.toLowerCase()) + } + case ComparisonOperators.EQUAL: { + return inputValue === value + } + case ComparisonOperators.NOT_EQUAL: { + return inputValue !== value + } + case ComparisonOperators.GREATER: { + return parseFloat(inputValue) > parseFloat(value) + } + case ComparisonOperators.LESS: { + return parseFloat(inputValue) < parseFloat(value) + } + case ComparisonOperators.IS_SET: { + return isDefined(inputValue) && inputValue.length > 0 + } + } + } diff --git a/apps/viewer/src/features/blocks/logic/redirect/api/index.ts b/apps/viewer/src/features/blocks/logic/redirect/api/index.ts new file mode 100644 index 000000000..e025d4927 --- /dev/null +++ b/apps/viewer/src/features/blocks/logic/redirect/api/index.ts @@ -0,0 +1 @@ +export { executeRedirect } from './utils/executeRedirect' diff --git a/apps/viewer/src/features/blocks/logic/redirect/api/utils/executeRedirect.ts b/apps/viewer/src/features/blocks/logic/redirect/api/utils/executeRedirect.ts new file mode 100644 index 000000000..99231bc7d --- /dev/null +++ b/apps/viewer/src/features/blocks/logic/redirect/api/utils/executeRedirect.ts @@ -0,0 +1,16 @@ +import { ExecuteLogicResponse } from '@/features/chat' +import { parseVariables } from '@/features/variables' +import { RedirectBlock, SessionState } from 'models' +import { sanitizeUrl } from 'utils' + +export const executeRedirect = ( + { typebot: { variables } }: SessionState, + block: RedirectBlock +): ExecuteLogicResponse => { + if (!block.options?.url) return { outgoingEdgeId: block.outgoingEdgeId } + const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url)) + return { + logic: { redirectUrl: formattedUrl }, + outgoingEdgeId: block.outgoingEdgeId, + } +} diff --git a/apps/viewer/src/features/blocks/logic/setVariable/api/index.ts b/apps/viewer/src/features/blocks/logic/setVariable/api/index.ts new file mode 100644 index 000000000..0d41cad27 --- /dev/null +++ b/apps/viewer/src/features/blocks/logic/setVariable/api/index.ts @@ -0,0 +1 @@ +export { executeSetVariable } from './utils/executeSetVariable' diff --git a/apps/viewer/src/features/blocks/logic/setVariable/api/utils/executeSetVariable.ts b/apps/viewer/src/features/blocks/logic/setVariable/api/utils/executeSetVariable.ts new file mode 100644 index 000000000..d6ec64451 --- /dev/null +++ b/apps/viewer/src/features/blocks/logic/setVariable/api/utils/executeSetVariable.ts @@ -0,0 +1,50 @@ +import { SessionState, SetVariableBlock, Variable } from 'models' +import { byId } from 'utils' +import { + parseVariables, + parseCorrectValueType, + updateVariables, +} from '@/features/variables' +import { ExecuteLogicResponse } from '@/features/chat' + +export const executeSetVariable = async ( + state: SessionState, + block: SetVariableBlock +): Promise => { + const { variables } = state.typebot + if (!block.options?.variableId) + return { + outgoingEdgeId: block.outgoingEdgeId, + } + const evaluatedExpression = block.options.expressionToEvaluate + ? evaluateSetVariableExpression(variables)( + block.options.expressionToEvaluate + ) + : undefined + const existingVariable = variables.find(byId(block.options.variableId)) + if (!existingVariable) return { outgoingEdgeId: block.outgoingEdgeId } + const newVariable = { + ...existingVariable, + value: evaluatedExpression, + } + const newSessionState = await updateVariables(state)([newVariable]) + return { + outgoingEdgeId: block.outgoingEdgeId, + newSessionState, + } +} + +const evaluateSetVariableExpression = + (variables: Variable[]) => + (str: string): unknown => { + const evaluating = parseVariables(variables, { fieldToParse: 'id' })( + str.includes('return ') ? str : `return ${str}` + ) + try { + const func = Function(...variables.map((v) => v.id), evaluating) + return func(...variables.map((v) => parseCorrectValueType(v.value))) + } catch (err) { + console.log(`Evaluating: ${evaluating}`, err) + return str + } + } diff --git a/apps/viewer/src/features/blocks/logic/typebotLink/api/index.ts b/apps/viewer/src/features/blocks/logic/typebotLink/api/index.ts new file mode 100644 index 000000000..9c56149ef --- /dev/null +++ b/apps/viewer/src/features/blocks/logic/typebotLink/api/index.ts @@ -0,0 +1 @@ +export * from './utils' diff --git a/apps/viewer/src/features/blocks/logic/typebotLink/api/utils/executeTypebotLink.ts b/apps/viewer/src/features/blocks/logic/typebotLink/api/utils/executeTypebotLink.ts new file mode 100644 index 000000000..d2a614def --- /dev/null +++ b/apps/viewer/src/features/blocks/logic/typebotLink/api/utils/executeTypebotLink.ts @@ -0,0 +1,138 @@ +import { ExecuteLogicResponse } from '@/features/chat' +import { saveErrorLog } from '@/features/logs/api' +import prisma from '@/lib/prisma' +import { TypebotLinkBlock, Edge, SessionState, TypebotInSession } from 'models' +import { byId } from 'utils' + +export const executeTypebotLink = async ( + state: SessionState, + block: TypebotLinkBlock +): Promise => { + if (!block.options.typebotId) { + saveErrorLog({ + resultId: state.result.id, + message: 'Failed to link typebot', + details: 'Typebot ID is not specified', + }) + return { outgoingEdgeId: block.outgoingEdgeId } + } + const linkedTypebot = await getLinkedTypebot(state, block.options.typebotId) + if (!linkedTypebot) { + saveErrorLog({ + resultId: state.result.id, + message: 'Failed to link typebot', + details: `Typebot with ID ${block.options.typebotId} not found`, + }) + return { outgoingEdgeId: block.outgoingEdgeId } + } + let newSessionState = addLinkedTypebotToState(state, block, linkedTypebot) + + const nextGroupId = + block.options.groupId ?? + linkedTypebot.groups.find((b) => b.blocks.some((s) => s.type === 'start')) + ?.id + if (!nextGroupId) { + saveErrorLog({ + resultId: state.result.id, + message: 'Failed to link typebot', + details: `Group with ID "${block.options.groupId}" not found`, + }) + return { outgoingEdgeId: block.outgoingEdgeId } + } + const portalEdge: Edge = { + id: (Math.random() * 1000).toString(), + from: { blockId: '', groupId: '' }, + to: { + groupId: nextGroupId, + }, + } + newSessionState = addEdgeToTypebot(newSessionState, portalEdge) + return { + outgoingEdgeId: portalEdge.id, + newSessionState, + } +} + +const addEdgeToTypebot = (state: SessionState, edge: Edge): SessionState => ({ + ...state, + typebot: { + ...state.typebot, + edges: [...state.typebot.edges, edge], + }, +}) + +const addLinkedTypebotToState = ( + state: SessionState, + block: TypebotLinkBlock, + linkedTypebot: TypebotInSession +): SessionState => ({ + ...state, + typebot: { + ...state.typebot, + groups: [...state.typebot.groups, ...linkedTypebot.groups], + variables: [...state.typebot.variables, ...linkedTypebot.variables], + edges: [...state.typebot.edges, ...linkedTypebot.edges], + }, + linkedTypebots: { + typebots: [ + ...state.linkedTypebots.typebots.filter( + (existingTypebots) => existingTypebots.id !== linkedTypebot.id + ), + ], + queue: block.outgoingEdgeId + ? [ + ...state.linkedTypebots.queue, + { edgeId: block.outgoingEdgeId, typebotId: state.currentTypebotId }, + ] + : state.linkedTypebots.queue, + }, + currentTypebotId: linkedTypebot.id, +}) + +const getLinkedTypebot = async ( + state: SessionState, + typebotId: string +): Promise => { + const { typebot, isPreview } = state + 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 }: Pick, + typebotId: string +): Promise => { + if (isPreview) { + const typebot = await prisma.typebot.findUnique({ + where: { id: typebotId }, + select: { + id: true, + edges: true, + groups: true, + variables: true, + }, + }) + return typebot as TypebotInSession + } + const typebot = await prisma.publicTypebot.findUnique({ + where: { typebotId }, + select: { + id: true, + edges: true, + groups: true, + variables: true, + }, + }) + if (!typebot) return null + return { + ...typebot, + id: typebotId, + } as TypebotInSession +} diff --git a/apps/viewer/src/features/typebotLink/api/getLinkedTypebots.ts b/apps/viewer/src/features/blocks/logic/typebotLink/api/utils/getLinkedTypebots.ts similarity index 96% rename from apps/viewer/src/features/typebotLink/api/getLinkedTypebots.ts rename to apps/viewer/src/features/blocks/logic/typebotLink/api/utils/getLinkedTypebots.ts index d227403c6..481195e7e 100644 --- a/apps/viewer/src/features/typebotLink/api/getLinkedTypebots.ts +++ b/apps/viewer/src/features/blocks/logic/typebotLink/api/utils/getLinkedTypebots.ts @@ -10,7 +10,7 @@ import { import { isDefined } from 'utils' export const getLinkedTypebots = async ( - typebot: Typebot | PublicTypebot, + typebot: Pick, user?: User ): Promise<(Typebot | PublicTypebot)[]> => { const linkedTypebotIds = ( diff --git a/apps/viewer/src/features/blocks/logic/typebotLink/api/utils/index.ts b/apps/viewer/src/features/blocks/logic/typebotLink/api/utils/index.ts new file mode 100644 index 000000000..7ba5f3173 --- /dev/null +++ b/apps/viewer/src/features/blocks/logic/typebotLink/api/utils/index.ts @@ -0,0 +1,2 @@ +export * from './executeTypebotLink' +export * from './getLinkedTypebots' diff --git a/apps/viewer/src/features/typebotLink/typebotLink.spec.ts b/apps/viewer/src/features/blocks/logic/typebotLink/typebotLink.spec.ts similarity index 100% rename from apps/viewer/src/features/typebotLink/typebotLink.spec.ts rename to apps/viewer/src/features/blocks/logic/typebotLink/typebotLink.spec.ts diff --git a/apps/viewer/src/features/chat/api/chatRouter.ts b/apps/viewer/src/features/chat/api/chatRouter.ts new file mode 100644 index 000000000..ebcf026b4 --- /dev/null +++ b/apps/viewer/src/features/chat/api/chatRouter.ts @@ -0,0 +1,6 @@ +import { router } from '@/utils/server/trpc' +import { sendMessageProcedure } from './procedures' + +export const chatRouter = router({ + sendMessage: sendMessageProcedure, +}) diff --git a/apps/viewer/src/features/chat/api/index.ts b/apps/viewer/src/features/chat/api/index.ts new file mode 100644 index 000000000..7449f8a51 --- /dev/null +++ b/apps/viewer/src/features/chat/api/index.ts @@ -0,0 +1,2 @@ +export * from './chatRouter' +export { getSession } from './utils' diff --git a/apps/viewer/src/features/chat/api/procedures/index.ts b/apps/viewer/src/features/chat/api/procedures/index.ts new file mode 100644 index 000000000..4a861457c --- /dev/null +++ b/apps/viewer/src/features/chat/api/procedures/index.ts @@ -0,0 +1 @@ +export * from './sendMessageProcedure' diff --git a/apps/viewer/src/features/chat/api/procedures/sendMessageProcedure.ts b/apps/viewer/src/features/chat/api/procedures/sendMessageProcedure.ts new file mode 100644 index 000000000..3005ff8dc --- /dev/null +++ b/apps/viewer/src/features/chat/api/procedures/sendMessageProcedure.ts @@ -0,0 +1,177 @@ +import prisma from '@/lib/prisma' +import { publicProcedure } from '@/utils/server/trpc' +import { TRPCError } from '@trpc/server' +import { + chatReplySchema, + ChatSession, + PublicTypebotWithName, + Result, + SessionState, + typebotSchema, +} from 'models' +import { z } from 'zod' +import { continueBotFlow, getSession, startBotFlow } from '../utils' + +export const sendMessageProcedure = publicProcedure + .meta({ + openapi: { + method: 'POST', + path: '/typebots/{typebotId}/sendMessage', + summary: 'Send a message', + description: + "To initiate a chat, don't provide a `sessionId` and enter any `message`.\n\nContinue the conversation by providing the `sessionId` and the `message` that should answer the previous question.\n\nSet the `isPreview` option to `true` to chat with the non-published version of the typebot.", + }, + }) + .input( + z.object({ + typebotId: z.string({ + description: + '[How can I find my typebot ID?](https://docs.typebot.io/api#how-to-find-my-typebotid)', + }), + message: z.string().describe('The answer to the previous question'), + sessionId: z + .string() + .optional() + .describe( + 'Session ID that you get from the initial chat request to a bot' + ), + isPreview: z.boolean().optional(), + }) + ) + .output( + chatReplySchema.and( + z.object({ + sessionId: z.string().nullish(), + typebot: typebotSchema.pick({ theme: true, settings: true }).nullish(), + }) + ) + ) + .query(async ({ input: { typebotId, sessionId, message } }) => { + const session = sessionId ? await getSession(sessionId) : null + + if (!session) { + const { sessionId, typebot, messages, input } = await startSession( + typebotId + ) + return { + sessionId, + typebot: typebot + ? { + theme: typebot.theme, + settings: typebot.settings, + } + : null, + messages, + input, + } + } else { + const { messages, input, logic, newSessionState } = await continueBotFlow( + session.state + )(message) + + await prisma.chatSession.updateMany({ + where: { id: session.id }, + data: { + state: newSessionState, + }, + }) + + return { + messages, + input, + logic, + } + } + }) + +const startSession = async (typebotId: string) => { + const typebot = await prisma.typebot.findUnique({ + where: { id: typebotId }, + select: { + publishedTypebot: true, + name: true, + isClosed: true, + isArchived: true, + id: true, + }, + }) + + if (!typebot?.publishedTypebot || typebot.isArchived) + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Typebot not found', + }) + + if (typebot.isClosed) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Typebot is closed', + }) + + const result = (await prisma.result.create({ + data: { isCompleted: false, typebotId }, + select: { + id: true, + variables: true, + hasStarted: true, + }, + })) as Pick + + const publicTypebot = typebot.publishedTypebot as PublicTypebotWithName + + const initialState: SessionState = { + typebot: { + id: publicTypebot.typebotId, + groups: publicTypebot.groups, + edges: publicTypebot.edges, + variables: publicTypebot.variables, + }, + linkedTypebots: { + typebots: [], + queue: [], + }, + result: { id: result.id, variables: [], hasStarted: false }, + isPreview: false, + currentTypebotId: publicTypebot.typebotId, + } + + const { + messages, + input, + logic, + newSessionState: newInitialState, + } = await startBotFlow(initialState) + + if (!input) + return { + messages, + typebot: null, + sessionId: null, + logic, + } + + const sessionState: ChatSession['state'] = { + ...(newInitialState ?? initialState), + currentBlock: { + groupId: input.groupId, + blockId: input.id, + }, + } + + const session = (await prisma.chatSession.create({ + data: { + state: sessionState, + }, + })) as ChatSession + + return { + sessionId: session.id, + typebot: { + theme: publicTypebot.theme, + settings: publicTypebot.settings, + }, + messages, + input, + logic, + } +} diff --git a/apps/viewer/src/features/chat/api/utils/continueBotFlow.ts b/apps/viewer/src/features/chat/api/utils/continueBotFlow.ts new file mode 100644 index 000000000..d3067490e --- /dev/null +++ b/apps/viewer/src/features/chat/api/utils/continueBotFlow.ts @@ -0,0 +1,150 @@ +import { validateButtonInput } from '@/features/blocks/inputs/buttons/api' +import { validateEmail } from '@/features/blocks/inputs/email/api' +import { validatePhoneNumber } from '@/features/blocks/inputs/phone/api' +import { validateUrl } from '@/features/blocks/inputs/url/api' +import prisma from '@/lib/prisma' +import { TRPCError } from '@trpc/server' +import { + Block, + BubbleBlockType, + ChatReply, + InputBlock, + InputBlockType, + SessionState, + Variable, +} from 'models' +import { isInputBlock } from 'utils' +import { executeGroup } from './executeGroup' +import { getNextGroup } from './getNextGroup' + +export const continueBotFlow = + (state: SessionState) => + async ( + reply: string + ): Promise => { + const group = state.typebot.groups.find( + (group) => group.id === state.currentBlock?.groupId + ) + const blockIndex = + group?.blocks.findIndex( + (block) => block.id === state.currentBlock?.blockId + ) ?? -1 + + const block = blockIndex > 0 ? group?.blocks[blockIndex ?? 0] : null + + if (!block || !group) + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Current block not found', + }) + + if (!isInputBlock(block)) + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Current block is not an input block', + }) + + if (!isInputValid(reply, block)) return parseRetryMessage(block) + + const newVariables = await processAndSaveAnswer(state, block)(reply) + + const newSessionState = { + ...state, + typebot: { + ...state.typebot, + variables: newVariables, + }, + } + + const groupHasMoreBlocks = blockIndex < group.blocks.length - 1 + + if (groupHasMoreBlocks) { + return executeGroup(newSessionState)({ + ...group, + blocks: group.blocks.slice(blockIndex + 1), + }) + } + + const nextEdgeId = block.outgoingEdgeId + + if (!nextEdgeId && state.linkedTypebots.queue.length === 0) + return { messages: [] } + + const nextGroup = getNextGroup(newSessionState)(nextEdgeId) + + if (!nextGroup) return { messages: [] } + + return executeGroup(newSessionState)(nextGroup.group) + } + +const processAndSaveAnswer = + (state: Pick, block: InputBlock) => + async (reply: string): Promise => { + await saveAnswer(state.result.id, block)(reply) + const newVariables = saveVariableValueIfAny(state, block)(reply) + return newVariables + } + +const saveVariableValueIfAny = + (state: Pick, block: InputBlock) => + (reply: string): Variable[] => { + if (!block.options.variableId) return state.typebot.variables + const variable = state.typebot.variables.find( + (variable) => variable.id === block.options.variableId + ) + if (!variable) return state.typebot.variables + + return [ + ...state.typebot.variables.filter( + (variable) => variable.id !== block.options.variableId + ), + { + ...variable, + value: reply, + }, + ] + } + +const parseRetryMessage = (block: InputBlock) => ({ + messages: [ + { + type: BubbleBlockType.TEXT, + content: { + plainText: + 'retryMessageContent' in block.options + ? block.options.retryMessageContent + : 'Invalid message. Please, try again.', + richText: [], + html: '', + }, + }, + ], + input: block, +}) + +const saveAnswer = + (resultId: string, block: InputBlock) => async (reply: string) => { + await prisma.answer.create({ + data: { + resultId: resultId, + blockId: block.id, + groupId: block.groupId, + content: reply, + variableId: block.options.variableId, + }, + }) + } + +export const isInputValid = (inputValue: string, block: Block): boolean => { + switch (block.type) { + case InputBlockType.EMAIL: + return validateEmail(inputValue) + case InputBlockType.PHONE: + return validatePhoneNumber(inputValue) + case InputBlockType.URL: + return validateUrl(inputValue) + case InputBlockType.CHOICE: + return validateButtonInput(block, inputValue) + } + return true +} diff --git a/apps/viewer/src/features/chat/api/utils/executeGroup.ts b/apps/viewer/src/features/chat/api/utils/executeGroup.ts new file mode 100644 index 000000000..324b9c620 --- /dev/null +++ b/apps/viewer/src/features/chat/api/utils/executeGroup.ts @@ -0,0 +1,113 @@ +import { parseVariables } from '@/features/variables' +import { + BubbleBlock, + BubbleBlockType, + ChatMessageContent, + ChatReply, + Group, + SessionState, +} from 'models' +import { + isBubbleBlock, + isInputBlock, + isIntegrationBlock, + isLogicBlock, +} from 'utils' +import { executeLogic } from './executeLogic' +import { getNextGroup } from './getNextGroup' +import { executeIntegration } from './executeIntegration' + +export const executeGroup = + (state: SessionState, currentReply?: ChatReply) => + async ( + group: Group + ): Promise => { + const messages: ChatReply['messages'] = currentReply?.messages ?? [] + let logic: ChatReply['logic'] = currentReply?.logic + let integrations: ChatReply['integrations'] = currentReply?.integrations + let nextEdgeId = null + + let newSessionState = state + + for (const block of group.blocks) { + nextEdgeId = block.outgoingEdgeId + + if (isBubbleBlock(block)) { + messages.push({ + type: block.type, + content: parseBubbleBlockContent(newSessionState)(block), + }) + continue + } + + if (isInputBlock(block)) + return { + messages, + input: block, + newSessionState: { + ...newSessionState, + currentBlock: { + groupId: group.id, + blockId: block.id, + }, + }, + } + const executionResponse = isLogicBlock(block) + ? await executeLogic(state)(block) + : isIntegrationBlock(block) + ? await executeIntegration(state)(block) + : null + + if (!executionResponse) continue + if ('logic' in executionResponse && executionResponse.logic) + logic = executionResponse.logic + if ('integrations' in executionResponse && executionResponse.integrations) + integrations = executionResponse.integrations + if (executionResponse.newSessionState) + newSessionState = executionResponse.newSessionState + if (executionResponse.outgoingEdgeId) + nextEdgeId = executionResponse.outgoingEdgeId + } + + if (!nextEdgeId) return { messages, newSessionState, logic, integrations } + + const nextGroup = getNextGroup(newSessionState)(nextEdgeId) + + if (nextGroup?.updatedContext) newSessionState = nextGroup.updatedContext + + if (!nextGroup) { + return { messages, newSessionState, logic, integrations } + } + + return executeGroup(newSessionState, { messages, logic, integrations })( + nextGroup.group + ) + } + +const parseBubbleBlockContent = + ({ typebot: { variables } }: SessionState) => + (block: BubbleBlock): ChatMessageContent => { + switch (block.type) { + case BubbleBlockType.TEXT: { + const plainText = parseVariables(variables)(block.content.plainText) + const html = parseVariables(variables)(block.content.html) + return { plainText, html } + } + case BubbleBlockType.IMAGE: { + const url = parseVariables(variables)(block.content.url) + return { url } + } + case BubbleBlockType.VIDEO: { + const url = parseVariables(variables)(block.content.url) + return { url } + } + case BubbleBlockType.AUDIO: { + const url = parseVariables(variables)(block.content.url) + return { url } + } + case BubbleBlockType.EMBED: { + const url = parseVariables(variables)(block.content.url) + return { url } + } + } + } diff --git a/apps/viewer/src/features/chat/api/utils/executeIntegration.ts b/apps/viewer/src/features/chat/api/utils/executeIntegration.ts new file mode 100644 index 000000000..e87ff861d --- /dev/null +++ b/apps/viewer/src/features/chat/api/utils/executeIntegration.ts @@ -0,0 +1,27 @@ +import { executeChatwootBlock } from '@/features/blocks/integrations/chatwoot/api' +import { executeGoogleAnalyticsBlock } from '@/features/blocks/integrations/googleAnalytics/api' +import { executeGoogleSheetBlock } from '@/features/blocks/integrations/googleSheets/api' +import { executeSendEmailBlock } from '@/features/blocks/integrations/sendEmail/api' +import { executeWebhookBlock } from '@/features/blocks/integrations/webhook/api' +import { IntegrationBlock, IntegrationBlockType, SessionState } from 'models' +import { ExecuteIntegrationResponse } from '../../types' + +export const executeIntegration = + (state: SessionState) => + async (block: IntegrationBlock): Promise => { + switch (block.type) { + case IntegrationBlockType.GOOGLE_SHEETS: + return executeGoogleSheetBlock(state, block) + case IntegrationBlockType.CHATWOOT: + return executeChatwootBlock(state, block) + case IntegrationBlockType.GOOGLE_ANALYTICS: + return executeGoogleAnalyticsBlock(state, block) + case IntegrationBlockType.EMAIL: + return executeSendEmailBlock(state, block) + case IntegrationBlockType.WEBHOOK: + case IntegrationBlockType.ZAPIER: + case IntegrationBlockType.MAKE_COM: + case IntegrationBlockType.PABBLY_CONNECT: + return executeWebhookBlock(state, block) + } + } diff --git a/apps/viewer/src/features/chat/api/utils/executeLogic.ts b/apps/viewer/src/features/chat/api/utils/executeLogic.ts new file mode 100644 index 000000000..5b7ed81d0 --- /dev/null +++ b/apps/viewer/src/features/chat/api/utils/executeLogic.ts @@ -0,0 +1,24 @@ +import { executeCode } from '@/features/blocks/logic/code/api' +import { executeCondition } from '@/features/blocks/logic/condition/api' +import { executeRedirect } from '@/features/blocks/logic/redirect/api' +import { executeSetVariable } from '@/features/blocks/logic/setVariable/api' +import { executeTypebotLink } from '@/features/blocks/logic/typebotLink/api' +import { LogicBlock, LogicBlockType, SessionState } from 'models' +import { ExecuteLogicResponse } from '../../types' + +export const executeLogic = + (state: SessionState) => + async (block: LogicBlock): Promise => { + switch (block.type) { + case LogicBlockType.SET_VARIABLE: + return executeSetVariable(state, block) + case LogicBlockType.CONDITION: + return executeCondition(state, block) + case LogicBlockType.REDIRECT: + return executeRedirect(state, block) + case LogicBlockType.CODE: + return executeCode(state, block) + case LogicBlockType.TYPEBOT_LINK: + return executeTypebotLink(state, block) + } + } diff --git a/apps/viewer/src/features/chat/api/utils/getNextGroup.ts b/apps/viewer/src/features/chat/api/utils/getNextGroup.ts new file mode 100644 index 000000000..44cc38aee --- /dev/null +++ b/apps/viewer/src/features/chat/api/utils/getNextGroup.ts @@ -0,0 +1,38 @@ +import { byId } from 'utils' +import { Group, SessionState } from 'models' + +export type NextGroup = { + group: Group + updatedContext?: SessionState +} + +export const getNextGroup = + (state: SessionState) => + (edgeId?: string): NextGroup | null => { + const { typebot } = state + const nextEdge = typebot.edges.find(byId(edgeId)) + if (!nextEdge) { + if (state.linkedTypebots.queue.length > 0) { + const nextEdgeId = state.linkedTypebots.queue[0].edgeId + const updatedContext = { + ...state, + linkedBotQueue: state.linkedTypebots.queue.slice(1), + } + const nextGroup = getNextGroup(updatedContext)(nextEdgeId) + if (!nextGroup) return null + return { + ...nextGroup, + updatedContext, + } + } + return null + } + const nextGroup = typebot.groups.find(byId(nextEdge.to.groupId)) + if (!nextGroup) return null + const startBlockIndex = nextEdge.to.blockId + ? nextGroup.blocks.findIndex(byId(nextEdge.to.blockId)) + : 0 + return { + group: { ...nextGroup, blocks: nextGroup.blocks.slice(startBlockIndex) }, + } + } diff --git a/apps/viewer/src/features/chat/api/utils/getSessionState.ts b/apps/viewer/src/features/chat/api/utils/getSessionState.ts new file mode 100644 index 000000000..11ff17cd6 --- /dev/null +++ b/apps/viewer/src/features/chat/api/utils/getSessionState.ts @@ -0,0 +1,12 @@ +import prisma from '@/lib/prisma' +import { ChatSession } from 'models' + +export const getSession = async ( + sessionId: string +): Promise | null> => { + const session = (await prisma.chatSession.findUnique({ + where: { id: sessionId }, + select: { id: true, state: true }, + })) as Pick | null + return session +} diff --git a/apps/viewer/src/features/chat/api/utils/index.ts b/apps/viewer/src/features/chat/api/utils/index.ts new file mode 100644 index 000000000..6d9a8531e --- /dev/null +++ b/apps/viewer/src/features/chat/api/utils/index.ts @@ -0,0 +1,5 @@ +export * from './continueBotFlow' +export * from './executeGroup' +export * from './getNextGroup' +export * from './getSessionState' +export * from './startBotFlow' diff --git a/apps/viewer/src/features/chat/api/utils/startBotFlow.ts b/apps/viewer/src/features/chat/api/utils/startBotFlow.ts new file mode 100644 index 000000000..f6f1d4528 --- /dev/null +++ b/apps/viewer/src/features/chat/api/utils/startBotFlow.ts @@ -0,0 +1,13 @@ +import { ChatReply, SessionState } from 'models' +import { executeGroup } from './executeGroup' +import { getNextGroup } from './getNextGroup' + +export const startBotFlow = async ( + state: SessionState +): Promise => { + const firstEdgeId = state.typebot.groups[0].blocks[0].outgoingEdgeId + if (!firstEdgeId) return { messages: [] } + const nextGroup = getNextGroup(state)(firstEdgeId) + if (!nextGroup) return { messages: [] } + return executeGroup(state)(nextGroup.group) +} diff --git a/apps/viewer/src/features/chat/chat.spec.ts b/apps/viewer/src/features/chat/chat.spec.ts new file mode 100644 index 000000000..0e622d21a --- /dev/null +++ b/apps/viewer/src/features/chat/chat.spec.ts @@ -0,0 +1,145 @@ +import { getTestAsset } from '@/test/utils/playwright' +import test, { expect } from '@playwright/test' +import cuid from 'cuid' +import { HttpMethod } from 'models' +import { + createWebhook, + deleteTypebots, + deleteWebhooks, + importTypebotInDatabase, +} from 'utils/playwright/databaseActions' + +const typebotId = cuid() +const publicId = `${typebotId}-public` + +test.beforeEach(async () => { + await importTypebotInDatabase(getTestAsset('typebots/chat/main.json'), { + id: typebotId, + publicId, + }) + await importTypebotInDatabase(getTestAsset('typebots/chat/linkedBot.json'), { + id: 'chat-sub-bot', + publicId: 'chat-sub-bot-public', + }) + await createWebhook(typebotId, { + id: 'chat-webhook-id', + method: HttpMethod.GET, + url: 'https://api.chucknorris.io/jokes/random', + }) +}) + +test.afterEach(async () => { + await deleteWebhooks(['chat-webhook-id']) + await deleteTypebots(['chat-sub-bot']) +}) + +test('API chat execution should work', async ({ request }) => { + let chatSessionId: string + + await test.step('Start the chat', async () => { + const { sessionId, messages, input } = await ( + await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, { + data: { + message: 'Hi', + }, + }) + ).json() + chatSessionId = sessionId + expect(sessionId).toBeDefined() + expect(messages[0].content.plainText).toBe('Hi there! ๐Ÿ‘‹') + expect(messages[1].content.plainText).toBe("Welcome. What's your name?") + expect(input.type).toBe('text input') + }) + + await test.step('Answer Name question', async () => { + const { messages, input } = await ( + await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, { + data: { message: 'John', sessionId: chatSessionId }, + }) + ).json() + expect(messages[0].content.plainText).toBe('Nice to meet you John') + expect(messages[1].content.url).toMatch(new RegExp('giphy.com', 'gm')) + expect(input.type).toBe('number input') + }) + + await test.step('Answer Age question', async () => { + const { messages, input } = await ( + await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, { + data: { message: '24', sessionId: chatSessionId }, + }) + ).json() + expect(messages[0].content.plainText).toBe('Ok, you are an adult then ๐Ÿ˜') + expect(messages[1].content.plainText).toBe('My magic number is 42') + expect(messages[2].content.plainText).toBe( + 'How would you rate the experience so far?' + ) + expect(input.type).toBe('rating input') + }) + + await test.step('Answer Rating question', async () => { + const { messages, input } = await ( + await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, { + data: { message: '8', sessionId: chatSessionId }, + }) + ).json() + expect(messages[0].content.plainText).toBe( + "I'm gonna shoot multiple inputs now..." + ) + expect(input.type).toBe('email input') + }) + + await test.step('Answer Email question with wrong input', async () => { + const { messages, input } = await ( + await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, { + data: { message: 'invalid email', sessionId: chatSessionId }, + }) + ).json() + expect(messages[0].content.plainText).toBe( + "This email doesn't seem to be valid. Can you type it again?" + ) + expect(input.type).toBe('email input') + }) + + await test.step('Answer Email question with valid input', async () => { + const { messages, input } = await ( + await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, { + data: { message: 'typebot@email.com', sessionId: chatSessionId }, + }) + ).json() + expect(messages.length).toBe(0) + expect(input.type).toBe('url input') + }) + + await test.step('Answer URL question', async () => { + const { messages, input } = await ( + await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, { + data: { message: 'https://typebot.io', sessionId: chatSessionId }, + }) + ).json() + expect(messages.length).toBe(0) + expect(input.type).toBe('choice input') + }) + + await test.step('Answer Buttons question with invalid choice', async () => { + const { messages, input } = await ( + await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, { + data: { message: 'Yolo', sessionId: chatSessionId }, + }) + ).json() + expect(messages[0].content.plainText).toBe( + 'Invalid message. Please, try again.' + ) + expect(input.type).toBe('choice input') + }) + + await test.step('Answer Buttons question with invalid choice', async () => { + const { messages } = await ( + await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, { + data: { message: 'Yes', sessionId: chatSessionId }, + }) + ).json() + expect(messages[0].content.plainText).toBe('Ok, you are solid ๐Ÿ‘') + expect(messages[1].content.plainText).toBe("Let's trigger a webhook...") + expect(messages[2].content.plainText.length).toBeGreaterThan(0) + }) +}) diff --git a/apps/viewer/src/features/chat/index.ts b/apps/viewer/src/features/chat/index.ts new file mode 100644 index 000000000..c9f6f047d --- /dev/null +++ b/apps/viewer/src/features/chat/index.ts @@ -0,0 +1 @@ +export * from './types' diff --git a/apps/viewer/src/features/chat/types.ts b/apps/viewer/src/features/chat/types.ts new file mode 100644 index 000000000..6385c0eb7 --- /dev/null +++ b/apps/viewer/src/features/chat/types.ts @@ -0,0 +1,13 @@ +import { ChatReply, SessionState } from 'models' + +export type EdgeId = string + +export type ExecuteLogicResponse = { + outgoingEdgeId: EdgeId | undefined + newSessionState?: SessionState +} & Pick + +export type ExecuteIntegrationResponse = { + outgoingEdgeId: EdgeId | undefined + newSessionState?: SessionState +} & Pick diff --git a/apps/viewer/src/features/logs/api/saveErrorLog.ts b/apps/viewer/src/features/logs/api/saveErrorLog.ts index be9bbffdd..0435e60a6 100644 --- a/apps/viewer/src/features/logs/api/saveErrorLog.ts +++ b/apps/viewer/src/features/logs/api/saveErrorLog.ts @@ -1,7 +1,11 @@ import { saveLog } from './utils' -export const saveErrorLog = ( - resultId: string | undefined, - message: string, +export const saveErrorLog = ({ + resultId, + message, + details, +}: { + resultId: string | undefined + message: string details?: unknown -) => saveLog('error', resultId, message, details) +}) => saveLog('error', resultId, message, details) diff --git a/apps/viewer/src/features/logs/api/saveSuccessLog.ts b/apps/viewer/src/features/logs/api/saveSuccessLog.ts index b480fcc14..3666de4ab 100644 --- a/apps/viewer/src/features/logs/api/saveSuccessLog.ts +++ b/apps/viewer/src/features/logs/api/saveSuccessLog.ts @@ -1,7 +1,11 @@ import { saveLog } from './utils' -export const saveSuccessLog = ( - resultId: string | undefined, - message: string, +export const saveSuccessLog = ({ + resultId, + message, + details, +}: { + resultId: string | undefined + message: string details?: unknown -) => saveLog('success', resultId, message, details) +}) => saveLog('success', resultId, message, details) diff --git a/apps/viewer/src/features/logs/api/utils.ts b/apps/viewer/src/features/logs/api/utils.ts index 60b404229..7b12085d8 100644 --- a/apps/viewer/src/features/logs/api/utils.ts +++ b/apps/viewer/src/features/logs/api/utils.ts @@ -1,4 +1,5 @@ import prisma from '@/lib/prisma' +import { isNotDefined } from 'utils' export const saveLog = ( status: 'error' | 'success', @@ -12,12 +13,13 @@ export const saveLog = ( resultId, status, description: message, - details: formatDetails(details) as string, + details: formatDetails(details) as string | null, }, }) } const formatDetails = (details: unknown) => { + if (isNotDefined(details)) return null try { return JSON.stringify(details, null, 2).substring(0, 1000) } catch { diff --git a/apps/viewer/src/features/results/api/index.ts b/apps/viewer/src/features/results/api/index.ts new file mode 100644 index 000000000..9c56149ef --- /dev/null +++ b/apps/viewer/src/features/results/api/index.ts @@ -0,0 +1 @@ +export * from './utils' diff --git a/apps/viewer/src/features/results/api/utils/getResultValues.ts b/apps/viewer/src/features/results/api/utils/getResultValues.ts new file mode 100644 index 000000000..09acd5973 --- /dev/null +++ b/apps/viewer/src/features/results/api/utils/getResultValues.ts @@ -0,0 +1,8 @@ +import prisma from '@/lib/prisma' +import { ResultValues } from 'models' + +export const getResultValues = async (resultId: string) => + (await prisma.result.findUnique({ + where: { id: resultId }, + include: { answers: true }, + })) as ResultValues | null diff --git a/apps/viewer/src/features/results/api/utils/index.ts b/apps/viewer/src/features/results/api/utils/index.ts new file mode 100644 index 000000000..20350d9df --- /dev/null +++ b/apps/viewer/src/features/results/api/utils/index.ts @@ -0,0 +1 @@ +export * from './getResultValues' diff --git a/apps/viewer/src/features/typebotLink/api/index.ts b/apps/viewer/src/features/typebotLink/api/index.ts deleted file mode 100644 index d64ba2009..000000000 --- a/apps/viewer/src/features/typebotLink/api/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './getLinkedTypebots' diff --git a/apps/viewer/src/features/variables/index.ts b/apps/viewer/src/features/variables/index.ts new file mode 100644 index 000000000..9c56149ef --- /dev/null +++ b/apps/viewer/src/features/variables/index.ts @@ -0,0 +1 @@ +export * from './utils' diff --git a/apps/viewer/src/features/variables/utils.ts b/apps/viewer/src/features/variables/utils.ts new file mode 100644 index 000000000..6f59d218a --- /dev/null +++ b/apps/viewer/src/features/variables/utils.ts @@ -0,0 +1,163 @@ +import prisma from '@/lib/prisma' +import { + SessionState, + Variable, + VariableWithUnknowValue, + VariableWithValue, +} from 'models' +import { isDefined, isNotDefined } from 'utils' + +export const stringContainsVariable = (str: string): boolean => + /\{\{(.*?)\}\}/g.test(str) + +export const parseVariables = + ( + variables: Variable[], + options: { fieldToParse?: 'value' | 'id'; escapeForJson?: boolean } = { + fieldToParse: 'value', + escapeForJson: false, + } + ) => + (text: string | undefined): string => { + if (!text || text === '') return '' + return text.replace(/\{\{(.*?)\}\}/g, (_, fullVariableString) => { + const matchedVarName = fullVariableString.replace(/{{|}}/g, '') + const variable = variables.find((v) => { + return matchedVarName === v.name && isDefined(v.value) + }) as VariableWithValue | undefined + if (!variable || variable.value === null) return '' + if (options.fieldToParse === 'id') return variable.id + const { value } = variable + if (options.escapeForJson) return jsonParse(value) + const parsedValue = safeStringify(value) + if (!parsedValue) return '' + return parsedValue + }) + } + +export const extractVariablesFromText = + (variables: Variable[]) => + (text: string): Variable[] => { + const matches = [...text.matchAll(/\{\{(.*?)\}\}/g)] + return matches.reduce((acc, match) => { + const variableName = match[1] + const variable = variables.find( + (variable) => variable.name === variableName + ) + if (!variable) return acc + return [...acc, variable] + }, []) + } + +export const safeStringify = (val: unknown): string | null => { + if (isNotDefined(val)) return null + if (typeof val === 'string') return val + try { + return JSON.stringify(val) + } catch { + console.warn('Failed to safely stringify variable value', val) + return null + } +} + +export const parseCorrectValueType = ( + value: Variable['value'] +): string | boolean | number | null | undefined => { + if (value === null) return null + if (value === undefined) return undefined + const isNumberStartingWithZero = + value.startsWith('0') && !value.startsWith('0.') && value.length > 1 + if (typeof value === 'string' && isNumberStartingWithZero) return value + if (typeof value === 'number') return value + if (value === 'true') return true + if (value === 'false') return false + if (value === 'null') return null + if (value === 'undefined') return undefined + // isNaN works with strings + if (isNaN(value as unknown as number)) return value + return Number(value) +} + +const jsonParse = (str: string) => + str + .replace(/\n/g, `\\n`) + .replace(/"/g, `\\"`) + .replace(/\\[^n"]/g, `\\\\ `) + +export const parseVariablesInObject = ( + object: { [key: string]: string | number }, + variables: Variable[] +) => + Object.keys(object).reduce((newObj, key) => { + const currentValue = object[key] + return { + ...newObj, + [key]: + typeof currentValue === 'string' + ? parseVariables(variables)(currentValue) + : currentValue, + } + }, {}) + +export const updateVariables = + (state: SessionState) => + async (newVariables: VariableWithUnknowValue[]): Promise => ({ + ...state, + typebot: { + ...state.typebot, + variables: updateTypebotVariables(state)(newVariables), + }, + result: { + ...state.result, + variables: await updateResultVariables(state)(newVariables), + }, + }) + +const updateResultVariables = + ({ result }: Pick) => + async ( + newVariables: VariableWithUnknowValue[] + ): Promise => { + const serializedNewVariables = newVariables.map((variable) => ({ + ...variable, + value: safeStringify(variable.value), + })) + + const updatedVariables = [ + ...result.variables.filter((existingVariable) => + serializedNewVariables.every( + (newVariable) => existingVariable.id !== newVariable.id + ) + ), + ...serializedNewVariables, + ].filter((variable) => isDefined(variable.value)) as VariableWithValue[] + + await prisma.result.update({ + where: { + id: result.id, + }, + data: { + variables: updatedVariables, + }, + }) + + return updatedVariables + } + +const updateTypebotVariables = + ({ typebot }: Pick) => + (newVariables: VariableWithUnknowValue[]): Variable[] => { + const serializedNewVariables = newVariables.map((variable) => ({ + ...variable, + value: safeStringify(variable.value), + })) + + return [ + ...typebot.variables.filter((existingVariable) => + serializedNewVariables.every( + (newVariable) => existingVariable.id !== newVariable.id + ) + ), + ...serializedNewVariables, + ] + } diff --git a/apps/viewer/src/features/webhook/api/index.ts b/apps/viewer/src/features/webhook/api/index.ts deleted file mode 100644 index a6a466c85..000000000 --- a/apps/viewer/src/features/webhook/api/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './parseSampleResult' diff --git a/apps/viewer/src/pages/api/integrations/google-sheets/spreadsheets/[spreadsheetId]/sheets/[sheetId].ts b/apps/viewer/src/pages/api/integrations/google-sheets/spreadsheets/[spreadsheetId]/sheets/[sheetId].ts index 12b0f7675..58860b955 100644 --- a/apps/viewer/src/pages/api/integrations/google-sheets/spreadsheets/[spreadsheetId]/sheets/[sheetId].ts +++ b/apps/viewer/src/pages/api/integrations/google-sheets/spreadsheets/[spreadsheetId]/sheets/[sheetId].ts @@ -39,7 +39,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { (row) => row[referenceCell.column as string] === referenceCell.value ) if (!row) { - await saveErrorLog(resultId, "Couldn't find reference cell") + await saveErrorLog({ + resultId, + message: "Couldn't find reference cell", + }) return res.status(404).send({ message: "Couldn't find row" }) } const response = { @@ -48,10 +51,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { {} ), } - await saveSuccessLog(resultId, 'Succesfully fetched spreadsheet data') + await saveSuccessLog({ + resultId, + message: 'Succesfully fetched spreadsheet data', + }) return res.send(response) } catch (err) { - await saveErrorLog(resultId, "Couldn't fetch spreadsheet data", err) + await saveErrorLog({ + resultId, + message: "Couldn't fetch spreadsheet data", + details: err, + }) return res.status(500).send(err) } } @@ -74,10 +84,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { await doc.loadInfo() const sheet = doc.sheetsById[sheetId] await sheet.addRow(values) - await saveSuccessLog(resultId, 'Succesfully inserted row') + await saveSuccessLog({ resultId, message: 'Succesfully inserted row' }) return res.send({ message: 'Success' }) } catch (err) { - await saveErrorLog(resultId, "Couldn't fetch spreadsheet data", err) + await saveErrorLog({ + resultId, + message: "Couldn't fetch spreadsheet data", + details: err, + }) return res.status(500).send(err) } } @@ -110,10 +124,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { rows[updatingRowIndex][key] = values[key] } await rows[updatingRowIndex].save() - await saveSuccessLog(resultId, 'Succesfully updated row') + await saveSuccessLog({ resultId, message: 'Succesfully updated row' }) return res.send({ message: 'Success' }) } catch (err) { - await saveErrorLog(resultId, "Couldn't fetch spreadsheet data", err) + await saveErrorLog({ + resultId, + message: "Couldn't fetch spreadsheet data", + details: err, + }) return res.status(500).send(err) } } 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 3ca27907d..761486050 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 @@ -20,9 +20,9 @@ import { stringify } from 'qs' import { withSentry } from '@sentry/nextjs' import Cors from 'cors' import prisma from '@/lib/prisma' -import { getLinkedTypebots } from '@/features/typebotLink/api' import { saveErrorLog, saveSuccessLog } from '@/features/logs/api' -import { parseSampleResult } from '@/features/webhook/api' +import { parseSampleResult } from '@/features/blocks/integrations/webhook/api' +import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api' const cors = initMiddleware(Cors()) @@ -149,10 +149,14 @@ export const executeWebhook = } try { const response = await got(request.url, omit(request, 'url')) - await saveSuccessLog(resultId, 'Webhook successfuly executed.', { - statusCode: response.statusCode, - request, - response: safeJsonParse(response.body).data, + await saveSuccessLog({ + resultId, + message: 'Webhook successfuly executed.', + details: { + statusCode: response.statusCode, + request, + response: safeJsonParse(response.body).data, + }, }) return { statusCode: response.statusCode, @@ -164,9 +168,13 @@ export const executeWebhook = statusCode: error.response.statusCode, data: safeJsonParse(error.response.body as string).data, } - await saveErrorLog(resultId, 'Webhook returned an error', { - request, - response, + await saveErrorLog({ + resultId, + message: 'Webhook returned an error', + details: { + request, + response, + }, }) return response } @@ -175,9 +183,13 @@ export const executeWebhook = data: { message: `Error from Typebot server: ${error}` }, } console.error(error) - await saveErrorLog(resultId, 'Webhook failed to execute', { - request, - response, + await saveErrorLog({ + resultId, + message: 'Webhook failed to execute', + details: { + request, + response, + }, }) return response } diff --git a/apps/viewer/src/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts b/apps/viewer/src/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts index 9170c0bc7..f7d2e5a13 100644 --- a/apps/viewer/src/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts +++ b/apps/viewer/src/pages/api/typebots/[typebotId]/blocks/[blockId]/sampleResult.ts @@ -1,6 +1,6 @@ import { authenticateUser } from '@/features/auth/api' -import { getLinkedTypebots } from '@/features/typebotLink/api' -import { parseSampleResult } from '@/features/webhook/api' +import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api' +import { parseSampleResult } from '@/features/blocks/integrations/webhook/api' import prisma from '@/lib/prisma' import { Typebot } from 'models' import { NextApiRequest, NextApiResponse } from 'next' diff --git a/apps/viewer/src/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/sampleResult.ts b/apps/viewer/src/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/sampleResult.ts index 76914a956..6dc1789a6 100644 --- a/apps/viewer/src/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/sampleResult.ts +++ b/apps/viewer/src/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/sampleResult.ts @@ -1,10 +1,10 @@ import { authenticateUser } from '@/features/auth/api' -import { getLinkedTypebots } from '@/features/typebotLink/api' +import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api' +import { parseSampleResult } from '@/features/blocks/integrations/webhook/api' import prisma from '@/lib/prisma' import { Typebot } from 'models' import { NextApiRequest, NextApiResponse } from 'next' import { methodNotAllowed } from 'utils/api' -import { parseSampleResult } from '@/features/webhook/api' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const user = await authenticateUser(req) 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 3a9861296..bbfe2483c 100644 --- a/apps/viewer/src/pages/api/typebots/[typebotId]/integrations/email.tsx +++ b/apps/viewer/src/pages/api/typebots/[typebotId]/integrations/email.tsx @@ -16,7 +16,7 @@ import Mail from 'nodemailer/lib/mailer' import { DefaultBotNotificationEmail } from 'emails' import { render } from '@faire/mjml-react/dist/src/utils/render' import prisma from '@/lib/prisma' -import { getLinkedTypebots } from '@/features/typebotLink/api' +import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api' const cors = initMiddleware(Cors()) @@ -84,14 +84,18 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { }) if (!emailBody) { - await saveErrorLog(resultId, 'Email not sent', { - transportConfig, - recipients, - subject, - cc, - bcc, - replyTo, - emailBody, + await saveErrorLog({ + resultId, + message: 'Email not sent', + details: { + transportConfig, + recipients, + subject, + cc, + bcc, + replyTo, + emailBody, + }, }) return res.status(404).send({ message: "Couldn't find email body" }) } @@ -109,12 +113,16 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { } try { const info = await transporter.sendMail(email) - await saveSuccessLog(resultId, 'Email successfully sent', { - transportConfig: { - ...transportConfig, - auth: { user: transportConfig.auth.user, pass: '******' }, + await saveSuccessLog({ + resultId, + message: 'Email successfully sent', + details: { + transportConfig: { + ...transportConfig, + auth: { user: transportConfig.auth.user, pass: '******' }, + }, + email, }, - email, }) return res.status(200).send({ message: 'Email sent!', @@ -122,13 +130,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { previewUrl: getTestMessageUrl(info), }) } catch (err) { - await saveErrorLog(resultId, 'Email not sent', { - transportConfig: { - ...transportConfig, - auth: { user: transportConfig.auth.user, pass: '******' }, + await saveErrorLog({ + resultId, + message: 'Email not sent', + details: { + transportConfig: { + ...transportConfig, + auth: { user: transportConfig.auth.user, pass: '******' }, + }, + email, + error: err, }, - email, - error: err, }) return res.status(500).send({ message: `Email not sent. Error: ${err}`, diff --git a/apps/viewer/src/pages/api/v1/[...trpc].ts b/apps/viewer/src/pages/api/v1/[...trpc].ts new file mode 100644 index 000000000..cafa24465 --- /dev/null +++ b/apps/viewer/src/pages/api/v1/[...trpc].ts @@ -0,0 +1,13 @@ +import { appRouter } from '@/utils/server/routers/v1/_app' +import { captureException } from '@sentry/nextjs' +import { createOpenApiNextHandler } from 'trpc-openapi' + +export default createOpenApiNextHandler({ + router: appRouter, + onError({ error }) { + if (error.code === 'INTERNAL_SERVER_ERROR') { + captureException(error) + console.error('Something went wrong', error) + } + }, +}) diff --git a/apps/viewer/src/test/assets/typebots/chat/linkedBot.json b/apps/viewer/src/test/assets/typebots/chat/linkedBot.json new file mode 100644 index 000000000..812e2dcb3 --- /dev/null +++ b/apps/viewer/src/test/assets/typebots/chat/linkedBot.json @@ -0,0 +1,107 @@ +{ + "id": "chat-sub-bot", + "createdAt": "2022-11-24T09:06:52.903Z", + "updatedAt": "2022-11-24T09:13:16.782Z", + "icon": "๐Ÿ‘ถ", + "name": "Sub bot", + "publishedTypebotId": null, + "folderId": null, + "groups": [ + { + "id": "clauup2lh0002vs1a5ei32mmi", + "title": "Start", + "blocks": [ + { + "id": "clauup2li0003vs1aas14fwpc", + "type": "start", + "label": "Start", + "groupId": "clauup2lh0002vs1a5ei32mmi", + "outgoingEdgeId": "clauupl9n001b3b6qdk4czgom" + } + ], + "graphCoordinates": { "x": 0, "y": 0 } + }, + { + "id": "clauupd6q00183b6qcm8qbz62", + "title": "Group #1", + "blocks": [ + { + "id": "clauupd6q00193b6qhegmlnxj", + "type": "text", + "content": { + "html": "
How would you rate the experience so far?
", + "richText": [ + { + "type": "p", + "children": [ + { "text": "How would you rate the experience so far?" } + ] + } + ], + "plainText": "How would you rate the experience so far?" + }, + "groupId": "clauupd6q00183b6qcm8qbz62" + }, + { + "id": "clauupk97001a3b6q2w9qqkec", + "type": "rating input", + "groupId": "clauupd6q00183b6qcm8qbz62", + "options": { + "labels": { "button": "Send" }, + "length": 10, + "buttonType": "Numbers", + "customIcon": { "isEnabled": false } + } + } + ], + "graphCoordinates": { "x": 375.36328125, "y": 167.2578125 } + } + ], + "variables": [], + "edges": [ + { + "id": "clauupl9n001b3b6qdk4czgom", + "to": { "groupId": "clauupd6q00183b6qcm8qbz62" }, + "from": { + "blockId": "clauup2li0003vs1aas14fwpc", + "groupId": "clauup2lh0002vs1a5ei32mmi" + } + } + ], + "theme": { + "chat": { + "inputs": { + "color": "#303235", + "backgroundColor": "#FFFFFF", + "placeholderColor": "#9095A0" + }, + "buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" }, + "hostAvatar": { + "url": "https://avatars.githubusercontent.com/u/16015833?v=4", + "isEnabled": true + }, + "hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" }, + "guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" } + }, + "general": { "font": "Open Sans", "background": { "type": "None" } } + }, + "settings": { + "general": { + "isBrandingEnabled": false, + "isInputPrefillEnabled": true, + "isResultSavingEnabled": true, + "isHideQueryParamsEnabled": true, + "isNewResultOnRefreshEnabled": false + }, + "metadata": { + "description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form." + }, + "typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 } + }, + "publicId": null, + "customDomain": null, + "workspaceId": "proWorkspace", + "resultsTablePreferences": null, + "isArchived": false, + "isClosed": false +} diff --git a/apps/viewer/src/test/assets/typebots/chat/main.json b/apps/viewer/src/test/assets/typebots/chat/main.json new file mode 100644 index 000000000..2329fb212 --- /dev/null +++ b/apps/viewer/src/test/assets/typebots/chat/main.json @@ -0,0 +1,515 @@ +{ + "id": "clauujawp00011avs2vj97zma", + "createdAt": "2022-11-24T09:02:23.737Z", + "updatedAt": "2022-11-24T09:12:57.036Z", + "icon": "๐Ÿค–", + "name": "Complete bot", + "publishedTypebotId": null, + "folderId": null, + "groups": [ + { + "id": "clauujawn0000vs1a8z6k2k7d", + "title": "Start", + "blocks": [ + { + "id": "clauujawn0001vs1a0mk8docp", + "type": "start", + "label": "Start", + "groupId": "clauujawn0000vs1a8z6k2k7d", + "outgoingEdgeId": "clauuk4o300083b6q7b2iowv3" + } + ], + "graphCoordinates": { "x": 0, "y": 0 } + }, + { + "id": "clauujxdc00063b6q42ca20gj", + "title": "Welcome", + "blocks": [ + { + "id": "clauujxdd00073b6qpejnkzcy", + "type": "text", + "content": { + "html": "
Hi there! ๐Ÿ‘‹
", + "richText": [ + { "type": "p", "children": [{ "text": "Hi there! ๐Ÿ‘‹" }] } + ], + "plainText": "Hi there! ๐Ÿ‘‹" + }, + "groupId": "clauujxdc00063b6q42ca20gj" + }, + { + "id": "clauukaad00093b6q07av51yc", + "type": "text", + "content": { + "html": "
Welcome. What's your name?
", + "richText": [ + { + "type": "p", + "children": [{ "text": "Welcome. What's your name?" }] + } + ], + "plainText": "Welcome. What's your name?" + }, + "groupId": "clauujxdc00063b6q42ca20gj" + }, + { + "id": "clauukip8000a3b6qtzl288tu", + "type": "text input", + "groupId": "clauujxdc00063b6q42ca20gj", + "options": { + "isLong": false, + "labels": { + "button": "Send", + "placeholder": "Type your answer..." + }, + "variableId": "vclauuklnc000b3b6q7xchq4yf" + }, + "outgoingEdgeId": "clauul0sk000f3b6q2tvy5wfi" + } + ], + "graphCoordinates": { "x": 5.81640625, "y": 172.359375 } + }, + { + "id": "clauukoka000c3b6qe6chawis", + "title": "Age", + "blocks": [ + { + "id": "clauukoka000d3b6qxqi38cmk", + "type": "text", + "content": { + "html": "
Nice to meet you {{Name}}
", + "richText": [ + { + "type": "p", + "children": [{ "text": "Nice to meet you {{Name}}" }] + } + ], + "plainText": "Nice to meet you {{Name}}" + }, + "groupId": "clauukoka000c3b6qe6chawis" + }, + { + "id": "clauuku5o000e3b6q90rm30p1", + "type": "image", + "content": { + "url": "https://media2.giphy.com/media/l0MYGb1LuZ3n7dRnO/giphy-downsized.gif?cid=fe3852a3yd2leg4yi8iual3wgyw893zzocuuqlp3wytt802h&rid=giphy-downsized.gif&ct=g" + }, + "groupId": "clauukoka000c3b6qe6chawis" + }, + { + "id": "clauul4vg000g3b6qr0q2h0uy", + "type": "text", + "content": { + "html": "
How old are you?
", + "richText": [ + { "type": "p", "children": [{ "text": "How old are you?" }] } + ], + "plainText": "How old are you?" + }, + "groupId": "clauukoka000c3b6qe6chawis" + }, + { + "id": "clauul90j000h3b6qjfrw9js4", + "type": "number input", + "groupId": "clauukoka000c3b6qe6chawis", + "options": { + "labels": { "button": "Send", "placeholder": "Type a number..." }, + "variableId": "vclauulfjk000i3b6qmujooweu" + }, + "outgoingEdgeId": "clauum41j000n3b6qpqu12icm" + } + ], + "graphCoordinates": { "x": 361.17578125, "y": 170.10546875 } + }, + { + "id": "clauulhqf000j3b6qm8y5oifc", + "title": "Is major?", + "blocks": [ + { + "id": "clauulhqf000k3b6qsrc1hd74", + "type": "Condition", + "items": [ + { + "id": "clauulhqg000l3b6qaxn4qli5", + "type": 1, + "blockId": "clauulhqf000k3b6qsrc1hd74", + "content": { + "comparisons": [ + { + "id": "clauuliyn000m3b6q10gwx8ii", + "value": "21", + "variableId": "vclauulfjk000i3b6qmujooweu", + "comparisonOperator": "Greater than" + } + ], + "logicalOperator": "AND" + }, + "outgoingEdgeId": "clauumi0x000q3b6q9bwkqnmr" + } + ], + "groupId": "clauulhqf000j3b6qm8y5oifc", + "outgoingEdgeId": "clauumm5v000t3b6qu62qcft8" + } + ], + "graphCoordinates": { "x": 726.2265625, "y": 240.80078125 } + }, + { + "id": "clauum8x7000o3b6qx8hqduf8", + "title": "Group #4", + "blocks": [ + { + "id": "clauum8x7000p3b6qxjud5hdc", + "type": "text", + "content": { + "html": "
Ok, you are an adult then ๐Ÿ˜
", + "richText": [ + { + "type": "p", + "children": [{ "text": "Ok, you are an adult then ๐Ÿ˜" }] + } + ], + "plainText": "Ok, you are an adult then ๐Ÿ˜" + }, + "groupId": "clauum8x7000o3b6qx8hqduf8", + "outgoingEdgeId": "clauuom2y000y3b6qkcjy2ri7" + } + ], + "graphCoordinates": { "x": 1073.38671875, "y": 232.25 } + }, + { + "id": "clauumjq4000r3b6q8l6bi9ra", + "title": "Group #4 copy", + "blocks": [ + { + "id": "clauumjq5000s3b6qqjhrklv4", + "type": "text", + "content": { + "html": "
Oh, you are a kid ๐Ÿ˜
", + "richText": [ + { "type": "p", "children": [{ "text": "Oh, you are a kid ๐Ÿ˜" }] } + ], + "plainText": "Oh, you are a kid ๐Ÿ˜" + }, + "groupId": "clauumjq4000r3b6q8l6bi9ra", + "outgoingEdgeId": "clauuol8t000x3b6qcw1few70" + } + ], + "graphCoordinates": { "x": 1073.984375, "y": 408.6875 } + }, + { + "id": "clauuoekh000u3b6q6zmlx7f9", + "title": "Magic number", + "blocks": [ + { + "id": "clauuoeki000v3b6qvsh7kde1", + "type": "Set variable", + "groupId": "clauuoekh000u3b6q6zmlx7f9", + "options": { + "variableId": "vclauuohyp000w3b6qbqrs6c6w", + "expressionToEvaluate": "42" + } + }, + { + "id": "clauuontu000z3b6q3ydx6ao1", + "type": "text", + "content": { + "html": "
My magic number is {{Magic number}}
", + "richText": [ + { + "type": "p", + "children": [{ "text": "My magic number is {{Magic number}}" }] + } + ], + "plainText": "My magic number is {{Magic number}}" + }, + "groupId": "clauuoekh000u3b6q6zmlx7f9", + "outgoingEdgeId": "clauuq8je001e3b6qksm4j11g" + } + ], + "graphCoordinates": { "x": 1465.359375, "y": 299.25390625 } + }, + { + "id": "clauuq2l6001c3b6qpmq3ivwk", + "graphCoordinates": { "x": 1836.43359375, "y": 295.39453125 }, + "title": "Rate the experience", + "blocks": [ + { + "id": "clauuq2l6001d3b6qyltfcvgb", + "groupId": "clauuq2l6001c3b6qpmq3ivwk", + "type": "Typebot link", + "options": { + "typebotId": "chat-sub-bot", + "groupId": "clauupd6q00183b6qcm8qbz62" + }, + "outgoingEdgeId": "clauureo3001h3b6qk6epabxq" + } + ] + }, + { + "id": "clauur7od001f3b6qq140oe55", + "graphCoordinates": { "x": 2201.45703125, "y": 299.1328125 }, + "title": "Multiple input in group", + "blocks": [ + { + "id": "clauur7od001g3b6qkoeij3f7", + "groupId": "clauur7od001f3b6qq140oe55", + "type": "text", + "content": { + "html": "
I'm gonna shoot multiple inputs now...
", + "richText": [ + { + "type": "p", + "children": [ + { "text": "I'm gonna shoot multiple inputs now..." } + ] + } + ], + "plainText": "I'm gonna shoot multiple inputs now..." + } + }, + { + "id": "clauurluf001i3b6qjf78puug", + "groupId": "clauur7od001f3b6qq140oe55", + "type": "email input", + "options": { + "labels": { "button": "Send", "placeholder": "Type your email..." }, + "retryMessageContent": "This email doesn't seem to be valid. Can you type it again?" + } + }, + { + "id": "clauurokp001j3b6qyrw7boca", + "groupId": "clauur7od001f3b6qq140oe55", + "type": "url input", + "options": { + "labels": { "button": "Send", "placeholder": "Type a URL..." }, + "retryMessageContent": "This URL doesn't seem to be valid. Can you type it again?" + } + }, + { + "id": "clauurs1o001k3b6qgrj0xf59", + "groupId": "clauur7od001f3b6qq140oe55", + "type": "choice input", + "options": { "buttonLabel": "Send", "isMultipleChoice": false }, + "items": [ + { + "id": "clauurs1o001l3b6qu9hr712h", + "blockId": "clauurs1o001k3b6qgrj0xf59", + "type": 0, + "content": "Yes" + }, + { + "id": "clauuru6t001m3b6qp8vkt23l", + "content": "No", + "blockId": "clauurs1o001k3b6qgrj0xf59", + "type": 0 + } + ], + "outgoingEdgeId": "clauushy3001p3b6qqnyrxgtb" + } + ] + }, + { + "id": "clauusa9z001n3b6qys3xvz1l", + "graphCoordinates": { "x": 2558.609375, "y": 297.078125 }, + "title": "Get Chuck Norris joke", + "blocks": [ + { + "id": "clauusaa0001o3b6qgddldaen", + "groupId": "clauusa9z001n3b6qys3xvz1l", + "type": "text", + "content": { + "html": "
Ok, you are solid ๐Ÿ‘
", + "richText": [ + { "type": "p", "children": [{ "text": "Ok, you are solid ๐Ÿ‘" }] } + ], + "plainText": "Ok, you are solid ๐Ÿ‘" + } + }, + { + "id": "clauusrfh001q3b6q7xaapi4h", + "groupId": "clauusa9z001n3b6qys3xvz1l", + "type": "text", + "content": { + "html": "
Let's trigger a webhook...
", + "richText": [ + { + "type": "p", + "children": [{ "text": "Let's trigger a webhook..." }] + } + ], + "plainText": "Let's trigger a webhook..." + } + }, + { + "id": "clauut2nq001r3b6qi437ixc7", + "groupId": "clauusa9z001n3b6qys3xvz1l", + "type": "Webhook", + "options": { + "responseVariableMapping": [ + { + "id": "clauuvvdr001t3b6qqdxzc057", + "bodyPath": "data.value", + "variableId": "vclauuwchv001u3b6qepx6e0a9" + } + ], + "variablesForTest": [], + "isAdvancedConfig": true, + "isCustomBody": false + }, + "webhookId": "chat-webhook-id", + "outgoingEdgeId": "clauuwjq2001x3b6qciu53855" + } + ] + }, + { + "id": "clauuwhyl001v3b6qarbpiqbv", + "graphCoordinates": { "x": 2900.9609375, "y": 288.29296875 }, + "title": "Display joke", + "blocks": [ + { + "id": "clauuwhyl001w3b6q7ai0zeyt", + "groupId": "clauuwhyl001v3b6qarbpiqbv", + "type": "text", + "content": { + "html": "
{{Joke}}
", + "richText": [{ "type": "p", "children": [{ "text": "{{Joke}}" }] }], + "plainText": "{{Joke}}" + } + } + ] + } + ], + "variables": [ + { "id": "vclauuklnc000b3b6q7xchq4yf", "name": "Name" }, + { "id": "vclauulfjk000i3b6qmujooweu", "name": "Age" }, + { "id": "vclauuohyp000w3b6qbqrs6c6w", "name": "Magic number" }, + { "id": "vclauuwchv001u3b6qepx6e0a9", "name": "Joke" } + ], + "edges": [ + { + "id": "clauuk4o300083b6q7b2iowv3", + "to": { "groupId": "clauujxdc00063b6q42ca20gj" }, + "from": { + "blockId": "clauujawn0001vs1a0mk8docp", + "groupId": "clauujawn0000vs1a8z6k2k7d" + } + }, + { + "id": "clauul0sk000f3b6q2tvy5wfi", + "to": { "groupId": "clauukoka000c3b6qe6chawis" }, + "from": { + "blockId": "clauukip8000a3b6qtzl288tu", + "groupId": "clauujxdc00063b6q42ca20gj" + } + }, + { + "id": "clauum41j000n3b6qpqu12icm", + "to": { "groupId": "clauulhqf000j3b6qm8y5oifc" }, + "from": { + "blockId": "clauul90j000h3b6qjfrw9js4", + "groupId": "clauukoka000c3b6qe6chawis" + } + }, + { + "id": "clauumi0x000q3b6q9bwkqnmr", + "to": { "groupId": "clauum8x7000o3b6qx8hqduf8" }, + "from": { + "itemId": "clauulhqg000l3b6qaxn4qli5", + "blockId": "clauulhqf000k3b6qsrc1hd74", + "groupId": "clauulhqf000j3b6qm8y5oifc" + } + }, + { + "id": "clauumm5v000t3b6qu62qcft8", + "to": { "groupId": "clauumjq4000r3b6q8l6bi9ra" }, + "from": { + "blockId": "clauulhqf000k3b6qsrc1hd74", + "groupId": "clauulhqf000j3b6qm8y5oifc" + } + }, + { + "id": "clauuol8t000x3b6qcw1few70", + "to": { "groupId": "clauuoekh000u3b6q6zmlx7f9" }, + "from": { + "blockId": "clauumjq5000s3b6qqjhrklv4", + "groupId": "clauumjq4000r3b6q8l6bi9ra" + } + }, + { + "id": "clauuom2y000y3b6qkcjy2ri7", + "to": { "groupId": "clauuoekh000u3b6q6zmlx7f9" }, + "from": { + "blockId": "clauum8x7000p3b6qxjud5hdc", + "groupId": "clauum8x7000o3b6qx8hqduf8" + } + }, + { + "from": { + "groupId": "clauuoekh000u3b6q6zmlx7f9", + "blockId": "clauuontu000z3b6q3ydx6ao1" + }, + "to": { "groupId": "clauuq2l6001c3b6qpmq3ivwk" }, + "id": "clauuq8je001e3b6qksm4j11g" + }, + { + "from": { + "groupId": "clauuq2l6001c3b6qpmq3ivwk", + "blockId": "clauuq2l6001d3b6qyltfcvgb" + }, + "to": { "groupId": "clauur7od001f3b6qq140oe55" }, + "id": "clauureo3001h3b6qk6epabxq" + }, + { + "from": { + "groupId": "clauur7od001f3b6qq140oe55", + "blockId": "clauurs1o001k3b6qgrj0xf59" + }, + "to": { "groupId": "clauusa9z001n3b6qys3xvz1l" }, + "id": "clauushy3001p3b6qqnyrxgtb" + }, + { + "from": { + "groupId": "clauusa9z001n3b6qys3xvz1l", + "blockId": "clauut2nq001r3b6qi437ixc7" + }, + "to": { "groupId": "clauuwhyl001v3b6qarbpiqbv" }, + "id": "clauuwjq2001x3b6qciu53855" + } + ], + "theme": { + "chat": { + "inputs": { + "color": "#303235", + "backgroundColor": "#FFFFFF", + "placeholderColor": "#9095A0" + }, + "buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" }, + "hostAvatar": { + "url": "https://avatars.githubusercontent.com/u/16015833?v=4", + "isEnabled": true + }, + "hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" }, + "guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" } + }, + "general": { "font": "Open Sans", "background": { "type": "None" } } + }, + "settings": { + "general": { + "isBrandingEnabled": false, + "isInputPrefillEnabled": true, + "isResultSavingEnabled": true, + "isHideQueryParamsEnabled": true, + "isNewResultOnRefreshEnabled": false + }, + "metadata": { + "description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form." + }, + "typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 } + }, + "publicId": null, + "customDomain": null, + "workspaceId": "proWorkspace", + "resultsTablePreferences": null, + "isArchived": false, + "isClosed": false +} diff --git a/apps/viewer/src/utils/server/generateOpenApi.ts b/apps/viewer/src/utils/server/generateOpenApi.ts new file mode 100644 index 000000000..c5a928291 --- /dev/null +++ b/apps/viewer/src/utils/server/generateOpenApi.ts @@ -0,0 +1,15 @@ +import { generateOpenApiDocument } from 'trpc-openapi' +import { writeFileSync } from 'fs' +import { appRouter } from './routers/v1/_app' + +const openApiDocument = generateOpenApiDocument(appRouter, { + title: 'Chat API', + version: '1.0.0', + baseUrl: 'https://typebot.io/api/v1', + docsUrl: 'https://docs.typebot.io/api', +}) + +writeFileSync( + './openapi/chat/_spec_.json', + JSON.stringify(openApiDocument, null, 2) +) diff --git a/apps/viewer/src/utils/server/routers/v1/_app.ts b/apps/viewer/src/utils/server/routers/v1/_app.ts new file mode 100644 index 000000000..085ecadc3 --- /dev/null +++ b/apps/viewer/src/utils/server/routers/v1/_app.ts @@ -0,0 +1,8 @@ +import { chatRouter } from '@/features/chat/api' +import { router } from '../../trpc' + +export const appRouter = router({ + chat: chatRouter, +}) + +export type AppRouter = typeof appRouter diff --git a/apps/viewer/src/utils/server/trpc.ts b/apps/viewer/src/utils/server/trpc.ts new file mode 100644 index 000000000..1babb310a --- /dev/null +++ b/apps/viewer/src/utils/server/trpc.ts @@ -0,0 +1,13 @@ +import { initTRPC } from '@trpc/server' +import { OpenApiMeta } from 'trpc-openapi' +import superjson from 'superjson' + +const t = initTRPC.meta().create({ + transformer: superjson, +}) + +export const middleware = t.middleware + +export const router = t.router + +export const publicProcedure = t.procedure diff --git a/packages/bot-engine/package.json b/packages/bot-engine/package.json index 06c6f1567..b96d81fe5 100644 --- a/packages/bot-engine/package.json +++ b/packages/bot-engine/package.json @@ -46,9 +46,7 @@ }, "peerDependencies": { "db": "workspace:*", - "models": "workspace:*", "react": "18.0.0", - "react-dom": "18.0.0", - "utils": "workspace:*" + "react-dom": "18.0.0" } } diff --git a/packages/db/prisma/migrations/20221129090341_add_chat_session/migration.sql b/packages/db/prisma/migrations/20221129090341_add_chat_session/migration.sql new file mode 100644 index 000000000..f64d34afb --- /dev/null +++ b/packages/db/prisma/migrations/20221129090341_add_chat_session/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "ChatSession" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "state" JSONB NOT NULL, + + CONSTRAINT "ChatSession_pkey" PRIMARY KEY ("id") +); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 2e2f80a9c..e80d6fb93 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -84,7 +84,7 @@ model Workspace { storageLimitFirstEmailSentAt DateTime? chatsLimitSecondEmailSentAt DateTime? storageLimitSecondEmailSentAt DateTime? - claimableCustomPlan ClaimableCustomPlan? + claimableCustomPlan ClaimableCustomPlan? customChatsLimit Int? customStorageLimit Int? customSeatsLimit Int? @@ -269,20 +269,27 @@ model Webhook { } model ClaimableCustomPlan { - id String @id @default(cuid()) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + createdAt DateTime @default(now()) claimedAt DateTime? name String description String? price Int currency String - workspaceId String @unique + workspaceId String @unique workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) chatsLimit Int storageLimit Int seatsLimit Int } +model ChatSession { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + state Json +} + enum WorkspaceRole { ADMIN MEMBER diff --git a/packages/models/package.json b/packages/models/package.json index 0a691a835..1318da18a 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -15,7 +15,6 @@ "tsconfig": "workspace:*" }, "peerDependencies": { - "next": "12.0.0", - "db": "workspace:*" + "next": "13.0.0" } } diff --git a/packages/models/src/features/answer.ts b/packages/models/src/features/answer.ts index 3f42e75a4..cca255f98 100644 --- a/packages/models/src/features/answer.ts +++ b/packages/models/src/features/answer.ts @@ -1,27 +1,34 @@ import { z } from 'zod' +import { schemaForType } from './utils' +import { Answer as AnswerPrisma, Prisma } from 'db' -export const answerSchema = z.object({ - createdAt: z.date(), - resultId: z.string(), - blockId: z.string(), - groupId: z.string(), - variableId: z.string().nullable(), - content: z.string(), - storageUsed: z.number().nullable(), -}) - -export const answerInputSchema = answerSchema - .omit({ - createdAt: true, - resultId: true, - variableId: true, - storageUsed: true, +export const answerSchema = schemaForType()( + z.object({ + createdAt: z.date(), + resultId: z.string(), + blockId: z.string(), + groupId: z.string(), + variableId: z.string().nullable(), + content: z.string(), + storageUsed: z.number().nullable(), }) - .and( - z.object({ - variableId: z.string().nullish(), - storageUsed: z.number().nullish(), - }) +) + +export const answerInputSchema = + schemaForType()( + answerSchema + .omit({ + createdAt: true, + resultId: true, + variableId: true, + storageUsed: true, + }) + .and( + z.object({ + variableId: z.string().nullish(), + storageUsed: z.number().nullish(), + }) + ) ) export type Stats = { diff --git a/packages/models/src/features/chat.ts b/packages/models/src/features/chat.ts new file mode 100644 index 000000000..cbc257f7c --- /dev/null +++ b/packages/models/src/features/chat.ts @@ -0,0 +1,101 @@ +import { z } from 'zod' +import { + audioBubbleContentSchema, + BubbleBlockType, + embedBubbleContentSchema, + googleAnalyticsOptionsSchema, + imageBubbleContentSchema, + inputBlockSchema, + textBubbleContentSchema, + videoBubbleContentSchema, +} from './blocks' +import { publicTypebotSchema } from './publicTypebot' +import { ChatSession as ChatSessionPrisma } from 'db' +import { schemaForType } from './utils' +import { resultSchema } from './result' + +const typebotInSessionStateSchema = publicTypebotSchema.pick({ + id: true, + groups: true, + edges: true, + variables: true, +}) + +export const sessionStateSchema = z.object({ + typebot: typebotInSessionStateSchema, + linkedTypebots: z.object({ + typebots: z.array(typebotInSessionStateSchema), + queue: z.array(z.object({ edgeId: z.string(), typebotId: z.string() })), + }), + currentTypebotId: z.string(), + result: resultSchema.pick({ id: true, variables: true, hasStarted: true }), + isPreview: z.boolean(), + currentBlock: z + .object({ + blockId: z.string(), + groupId: z.string(), + }) + .optional(), +}) + +const chatSessionSchema = schemaForType()( + z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + state: sessionStateSchema, + }) +) + +const simplifiedTextBubbleContentSchema = textBubbleContentSchema.pick({ + plainText: true, + html: true, +}) + +const chatMessageContentSchema = simplifiedTextBubbleContentSchema + .or(imageBubbleContentSchema) + .or(videoBubbleContentSchema) + .or(embedBubbleContentSchema) + .or(audioBubbleContentSchema) + +const codeToExecuteSchema = z.object({ + content: z.string(), + args: z.array( + z.object({ + id: z.string(), + value: z.string().or(z.number()).or(z.boolean()).nullish(), + }) + ), +}) + +export const chatReplySchema = z.object({ + messages: z.array( + z.object({ + type: z.nativeEnum(BubbleBlockType), + content: chatMessageContentSchema, + }) + ), + input: inputBlockSchema.optional(), + logic: z + .object({ + redirectUrl: z.string().optional(), + codeToExecute: codeToExecuteSchema.optional(), + }) + .optional(), + integrations: z + .object({ + chatwoot: z + .object({ + codeToExecute: codeToExecuteSchema, + }) + .optional(), + googleAnalytics: googleAnalyticsOptionsSchema.optional(), + }) + .optional(), +}) + +export type ChatSession = z.infer +export type SessionState = z.infer +export type TypebotInSession = z.infer +export type ChatReply = z.infer +export type ChatMessageContent = z.infer diff --git a/packages/models/src/features/publicTypebot.ts b/packages/models/src/features/publicTypebot.ts index d2ae77793..48d1fdda0 100644 --- a/packages/models/src/features/publicTypebot.ts +++ b/packages/models/src/features/publicTypebot.ts @@ -1,21 +1,32 @@ -import { Group, Edge, Settings, Theme, Variable } from './typebot' -import { PublicTypebot as PublicTypebotFromPrisma } from 'db' +import { + groupSchema, + edgeSchema, + variableSchema, + themeSchema, + settingsSchema, + typebotSchema, +} from './typebot' +import { PublicTypebot as PublicTypebotPrisma } from 'db' +import { z } from 'zod' +import { schemaForType } from './utils' -export type PublicTypebot = Omit< - PublicTypebotFromPrisma, - | 'groups' - | 'theme' - | 'settings' - | 'variables' - | 'edges' - | 'createdAt' - | 'updatedAt' -> & { - groups: Group[] - variables: Variable[] - edges: Edge[] - theme: Theme - settings: Settings - createdAt: string - updatedAt: string -} +export const publicTypebotSchema = schemaForType()( + z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + typebotId: z.string(), + groups: z.array(groupSchema), + edges: z.array(edgeSchema), + variables: z.array(variableSchema), + theme: themeSchema, + settings: settingsSchema, + }) +) + +const publicTypebotWithName = publicTypebotSchema.and( + typebotSchema.pick({ name: true, isArchived: true, isClosed: true }) +) + +export type PublicTypebot = z.infer +export type PublicTypebotWithName = z.infer diff --git a/packages/models/src/features/result.ts b/packages/models/src/features/result.ts index 1433955a4..e294e98b1 100644 --- a/packages/models/src/features/result.ts +++ b/packages/models/src/features/result.ts @@ -2,17 +2,21 @@ import { z } from 'zod' import { answerInputSchema, answerSchema } from './answer' import { InputBlockType } from './blocks' import { variableWithValueSchema } from './typebot/variable' +import { Result as ResultPrisma, Log as LogPrisma } from 'db' +import { schemaForType } from './utils' -export const resultSchema = z.object({ - id: z.string(), - createdAt: z.date(), - updatedAt: z.date(), - typebotId: z.string(), - variables: z.array(variableWithValueSchema), - isCompleted: z.boolean(), - hasStarted: z.boolean().nullable(), - isArchived: z.boolean().nullable(), -}) +export const resultSchema = schemaForType()( + z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + typebotId: z.string(), + variables: z.array(variableWithValueSchema), + isCompleted: z.boolean(), + hasStarted: z.boolean().nullable(), + isArchived: z.boolean().nullable(), + }) +) export const resultWithAnswersSchema = resultSchema.and( z.object({ @@ -26,23 +30,22 @@ export const resultWithAnswersInputSchema = resultSchema.and( }) ) -export const logSchema = z.object({ - id: z.string(), - createdAt: z.date(), - resultId: z.string(), - status: z.string(), - description: z.string(), - details: z.string().nullable(), -}) +export const logSchema = schemaForType()( + z.object({ + id: z.string(), + createdAt: z.date(), + resultId: z.string(), + status: z.string(), + description: z.string(), + details: z.string().nullable(), + }) +) export type Result = z.infer - export type ResultWithAnswers = z.infer - export type ResultWithAnswersInput = z.infer< typeof resultWithAnswersInputSchema > - export type Log = z.infer export type ResultValues = Pick< diff --git a/packages/models/src/features/typebot/typebot.ts b/packages/models/src/features/typebot/typebot.ts index e5365629b..fda2e6ad9 100644 --- a/packages/models/src/features/typebot/typebot.ts +++ b/packages/models/src/features/typebot/typebot.ts @@ -3,8 +3,10 @@ import { settingsSchema } from './settings' import { blockSchema } from '../blocks' import { themeSchema } from './theme' import { variableSchema } from './variable' +import { Typebot as TypebotPrisma } from 'db' +import { schemaForType } from '../utils' -const groupSchema = z.object({ +export const groupSchema = z.object({ id: z.string(), title: z.string(), graphCoordinates: z.object({ @@ -25,7 +27,7 @@ const targetSchema = z.object({ blockId: z.string().optional(), }) -const edgeSchema = z.object({ +export const edgeSchema = z.object({ id: z.string(), from: sourceSchema, to: targetSchema, @@ -37,27 +39,29 @@ const resultsTablePreferencesSchema = z.object({ columnsWidth: z.record(z.string(), z.number()), }) -const typebotSchema = z.object({ - version: z.enum(['2']).optional(), - id: z.string(), - name: z.string(), - groups: z.array(groupSchema), - edges: z.array(edgeSchema), - variables: z.array(variableSchema), - theme: themeSchema, - settings: settingsSchema, - createdAt: z.string(), - updatedAt: z.string(), - icon: z.string().nullable(), - publishedTypebotId: z.string().nullable(), - folderId: z.string().nullable(), - publicId: z.string().nullable(), - customDomain: z.string().nullable(), - workspaceId: z.string(), - resultsTablePreferences: resultsTablePreferencesSchema.optional(), - isArchived: z.boolean(), - isClosed: z.boolean(), -}) +export const typebotSchema = schemaForType()( + z.object({ + version: z.enum(['2']).optional(), + id: z.string(), + name: z.string(), + groups: z.array(groupSchema), + edges: z.array(edgeSchema), + variables: z.array(variableSchema), + theme: themeSchema, + settings: settingsSchema, + createdAt: z.date(), + updatedAt: z.date(), + icon: z.string().nullable(), + publishedTypebotId: z.string().nullable(), + folderId: z.string().nullable(), + publicId: z.string().nullable(), + customDomain: z.string().nullable(), + workspaceId: z.string(), + resultsTablePreferences: resultsTablePreferencesSchema.nullable(), + isArchived: z.boolean(), + isClosed: z.boolean(), + }) +) export type Typebot = z.infer export type Target = z.infer diff --git a/packages/models/src/features/utils.ts b/packages/models/src/features/utils.ts index fdd9fdf5e..0dbc17252 100644 --- a/packages/models/src/features/utils.ts +++ b/packages/models/src/features/utils.ts @@ -1 +1,9 @@ +import { z } from 'zod' + export type IdMap = { [id: string]: T } + +export const schemaForType = + () => + >(arg: S) => { + return arg + } diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 641b671d4..2958457cf 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -6,3 +6,4 @@ export * from './features/answer' export * from './features/utils' export * from './features/credentials' export * from './features/webhooks' +export * from './features/chat' diff --git a/packages/utils/package.json b/packages/utils/package.json index 2c166893a..09e9e8a58 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -20,9 +20,7 @@ }, "peerDependencies": { "aws-sdk": "2.1152.0", - "db": "workspace:*", - "models": "workspace:*", - "next": "12.0.0", + "next": "13.0.0", "nodemailer": "6.7.8" } } diff --git a/packages/utils/playwright/databaseActions.ts b/packages/utils/playwright/databaseActions.ts index 02b1d3257..083f9e41c 100644 --- a/packages/utils/playwright/databaseActions.ts +++ b/packages/utils/playwright/databaseActions.ts @@ -79,7 +79,7 @@ export const importTypebotInDatabase = async ( ...updates, } await prisma.typebot.create({ - data: typebot, + data: parseCreateTypebot(typebot), }) return prisma.publicTypebot.create({ data: parseTypebotToPublicTypebot( @@ -163,7 +163,7 @@ export const createTypebots = async (partialTypebots: Partial[]) => { } }) await prisma.typebot.createMany({ - data: typebotsWithId.map(parseTestTypebot), + data: typebotsWithId.map(parseTestTypebot).map(parseCreateTypebot), }) return prisma.publicTypebot.createMany({ data: typebotsWithId.map((t) => @@ -177,7 +177,7 @@ export const updateTypebot = async ( ) => { await prisma.typebot.updateMany({ where: { id: partialTypebot.id }, - data: partialTypebot, + data: parseUpdateTypebot(partialTypebot), }) return prisma.publicTypebot.updateMany({ where: { typebotId: partialTypebot.id }, @@ -194,3 +194,19 @@ export const updateWorkspace = async ( data, }) } + +const parseCreateTypebot = (typebot: Typebot) => ({ + ...typebot, + resultsTablePreferences: + typebot.resultsTablePreferences === null + ? Prisma.DbNull + : typebot.resultsTablePreferences, +}) + +const parseUpdateTypebot = (typebot: Partial) => ({ + ...typebot, + resultsTablePreferences: + typebot.resultsTablePreferences === null + ? Prisma.DbNull + : typebot.resultsTablePreferences, +}) diff --git a/packages/utils/playwright/databaseHelpers.ts b/packages/utils/playwright/databaseHelpers.ts index 8e4dea861..d74c58ec2 100644 --- a/packages/utils/playwright/databaseHelpers.ts +++ b/packages/utils/playwright/databaseHelpers.ts @@ -22,13 +22,14 @@ export const parseTestTypebot = ( theme: defaultTheme, settings: defaultSettings, publicId: null, - updatedAt: new Date().toISOString(), - createdAt: new Date().toISOString(), + updatedAt: new Date(), + createdAt: new Date(), publishedTypebotId: null, customDomain: null, icon: null, isArchived: false, isClosed: false, + resultsTablePreferences: null, variables: [{ id: 'var1', name: 'var1' }], ...partialTypebot, edges: [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36c989ef8..d1789a670 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,9 +235,9 @@ importers: '@docusaurus/types': ^2.2.0 '@mdx-js/react': 1.6.22 '@svgr/webpack': 6.5.1 + '@typebot.io/docusaurus-preset-openapi': 0.6.5 '@types/react': 18.0.25 clsx: 1.2.1 - docusaurus-preset-openapi: ^0.6.3 file-loader: 6.2.0 prism-react-renderer: 1.3.5 react: 17.0.2 @@ -253,8 +253,8 @@ importers: '@docusaurus/theme-search-algolia': 2.2.0_ysds3xf44nkbridx5gpbszqrsm '@mdx-js/react': 1.6.22_react@17.0.2 '@svgr/webpack': 6.5.1 + '@typebot.io/docusaurus-preset-openapi': 0.6.5_bitjua5s3tgsymg7hzgopoaz4a clsx: 1.2.1 - docusaurus-preset-openapi: 0.6.3_bitjua5s3tgsymg7hzgopoaz4a file-loader: 6.2.0_webpack@5.75.0 prism-react-renderer: 1.3.5_react@17.0.2 react: 17.0.2 @@ -339,6 +339,7 @@ importers: '@faire/mjml-react': 3.0.0 '@playwright/test': 1.28.1 '@sentry/nextjs': 7.21.1 + '@trpc/server': 10.3.0 '@types/cors': 2.8.12 '@types/google-spreadsheet': 3.3.0 '@types/node': 18.11.9 @@ -348,7 +349,7 @@ importers: '@types/react': 18.0.25 '@types/sanitize-html': 2.6.2 aws-sdk: 2.1261.0 - bot-engine: '*' + bot-engine: workspace:* cors: 2.8.5 cuid: 2.1.8 db: workspace:* @@ -370,11 +371,15 @@ importers: react-dom: 18.2.0 sanitize-html: 2.7.3 stripe: 11.1.0 + superjson: ^1.11.0 + trpc-openapi: 1.0.0-alpha.4 tsconfig: workspace:* typescript: 4.9.3 utils: workspace:* + zod: 3.19.1 dependencies: '@sentry/nextjs': 7.21.1_next@13.0.5+react@18.2.0 + '@trpc/server': 10.3.0 aws-sdk: 2.1261.0 bot-engine: link:../../packages/bot-engine cors: 2.8.5 @@ -389,6 +394,7 @@ importers: react-dom: 18.2.0_react@18.2.0 sanitize-html: 2.7.3 stripe: 11.1.0 + trpc-openapi: 1.0.0-alpha.4_xfmifnqysbeeolwxnsx4yhzpwe devDependencies: '@babel/preset-env': 7.20.2_@babel+core@7.20.2 '@faire/mjml-react': 3.0.0_7tbcn2mecc3yvuxakflodiks3m @@ -410,9 +416,11 @@ importers: next-transpile-modules: 10.0.0 node-fetch: 3.3.0 papaparse: 5.3.2 + superjson: 1.11.0 tsconfig: link:../../packages/tsconfig typescript: 4.9.3 utils: link:../../packages/utils + zod: 3.19.1 packages/bot-engine: specifiers: @@ -5516,6 +5524,109 @@ packages: engines: {node: '>=10.13.0'} dev: false + /@typebot.io/docusaurus-plugin-openapi/0.6.5_zneentkx4scexj4pzosurqq55y: + resolution: {integrity: sha512-HCCkKNkwL8Ah3A+mZPibDr8argAU31+Z0ZZVCZtArfw4+IMDmoEZl4DKy5W+Ryu0EGQ3oink0B8cE15RP/uABw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.8.4 || ^17.0.0 + react-dom: ^16.8.4 || ^17.0.0 + dependencies: + '@docusaurus/mdx-loader': 2.2.0_zneentkx4scexj4pzosurqq55y + '@docusaurus/plugin-content-docs': 2.2.0_sfoxds7t5ydpegc3knd667wn6m + '@docusaurus/utils': 2.2.0_@docusaurus+types@2.2.0 + '@docusaurus/utils-validation': 2.2.0_@docusaurus+types@2.2.0 + axios: 0.26.1 + chalk: 4.1.2 + clsx: 1.2.1 + fs-extra: 9.1.0 + js-yaml: 4.1.0 + json-refs: 3.0.15 + json-schema-resolve-allof: 1.5.0 + lodash: 4.17.21 + openapi-to-postmanv2: 1.2.7 + postman-collection: 4.1.5 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + remark-admonitions: 1.2.1 + webpack: 5.75.0 + transitivePeerDependencies: + - '@docusaurus/types' + - '@swc/core' + - debug + - esbuild + - supports-color + - uglify-js + - webpack-cli + dev: false + + /@typebot.io/docusaurus-plugin-proxy/0.6.5: + resolution: {integrity: sha512-m2hPJvGhVbxadZ7cMoxXjLUw9KuVkogtSGiYqAVHkwkjfx1K/+umwPM71nXqBbF/APY8kuT9SSX0TpDoK9R3Yw==} + engines: {node: '>=14'} + dev: false + + /@typebot.io/docusaurus-preset-openapi/0.6.5_bitjua5s3tgsymg7hzgopoaz4a: + resolution: {integrity: sha512-NM5+IqqBtV5WpIb0Ouba4VL0D/FivIxRGNlW3tLtk+i2JESUECgin8R/HGp/QXwJv5+/Qtox9EPzkVVW5N3rvA==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.8.4 || ^17.0.0 + react-dom: ^16.8.4 || ^17.0.0 + dependencies: + '@typebot.io/docusaurus-plugin-openapi': 0.6.5_zneentkx4scexj4pzosurqq55y + '@typebot.io/docusaurus-plugin-proxy': 0.6.5 + '@typebot.io/docusaurus-theme-openapi': 0.6.5_bitjua5s3tgsymg7hzgopoaz4a + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + transitivePeerDependencies: + - '@docusaurus/types' + - '@swc/core' + - debug + - esbuild + - react-native + - redux + - supports-color + - uglify-js + - webpack-cli + dev: false + + /@typebot.io/docusaurus-theme-openapi/0.6.5_bitjua5s3tgsymg7hzgopoaz4a: + resolution: {integrity: sha512-nTLJsWBPEeRZBHU2kcSdFXFCQnwllaiaDtx6yD1hNmKk02jhecVpX2mpEfNBOh24C/s+cUzT0ME9BqJ8RCcWNQ==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.8.4 || ^17.0.0 + react-dom: ^16.8.4 || ^17.0.0 + dependencies: + '@mdx-js/react': 1.6.22_react@17.0.2 + '@monaco-editor/react': 4.4.6_mdctwmt4gahkxlfxf2gmcvzx3a + '@reduxjs/toolkit': 1.9.0_csuupx2mfbspdz76fkecdgkise + '@typebot.io/docusaurus-plugin-openapi': 0.6.5_zneentkx4scexj4pzosurqq55y + buffer: 6.0.3 + clsx: 1.2.1 + crypto-js: 4.1.1 + immer: 9.0.16 + lodash: 4.17.21 + monaco-editor: 0.31.1 + postman-code-generators: 1.2.2 + postman-collection: 4.1.5 + prism-react-renderer: 1.3.5_react@17.0.2 + process: 0.11.10 + react: 17.0.2 + react-dom: 17.0.2_react@17.0.2 + react-magic-dropzone: 1.0.1 + react-redux: 7.2.9_sfoxds7t5ydpegc3knd667wn6m + redux-devtools-extension: 2.13.9_redux@4.2.0 + webpack: 5.75.0 + transitivePeerDependencies: + - '@docusaurus/types' + - '@swc/core' + - debug + - esbuild + - react-native + - redux + - supports-color + - uglify-js + - webpack-cli + dev: false + /@types/aos/3.0.4: resolution: {integrity: sha512-mna6Jd6bdK1NpwarLopGvXOgUoCfj0470IwLxuVOFDElTGI0JTd7xSGQ0AjbAEnHErC/b3fA9t2uB3IXVKmckA==} dev: true @@ -8821,109 +8932,6 @@ packages: dependencies: esutils: 2.0.3 - /docusaurus-plugin-openapi/0.6.3_zneentkx4scexj4pzosurqq55y: - resolution: {integrity: sha512-2i/f4zfW5aCNe8Ids6WsgSPQHFEdAwvhoe0lwb83QkNE33bYerZmoqSjJr6wSZsAEsb1HkDW+JLZNxkjSAHe2w==} - engines: {node: '>=14'} - peerDependencies: - react: ^16.8.4 || ^17.0.0 - react-dom: ^16.8.4 || ^17.0.0 - dependencies: - '@docusaurus/mdx-loader': 2.2.0_zneentkx4scexj4pzosurqq55y - '@docusaurus/plugin-content-docs': 2.2.0_sfoxds7t5ydpegc3knd667wn6m - '@docusaurus/utils': 2.2.0_@docusaurus+types@2.2.0 - '@docusaurus/utils-validation': 2.2.0_@docusaurus+types@2.2.0 - axios: 0.26.1 - chalk: 4.1.2 - clsx: 1.2.1 - fs-extra: 9.1.0 - js-yaml: 4.1.0 - json-refs: 3.0.15 - json-schema-resolve-allof: 1.5.0 - lodash: 4.17.21 - openapi-to-postmanv2: 1.2.7 - postman-collection: 4.1.5 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - remark-admonitions: 1.2.1 - webpack: 5.75.0 - transitivePeerDependencies: - - '@docusaurus/types' - - '@swc/core' - - debug - - esbuild - - supports-color - - uglify-js - - webpack-cli - dev: false - - /docusaurus-plugin-proxy/0.6.3: - resolution: {integrity: sha512-HAR76IsuSWlVI1K6P8fEJDjhHxT3LLdXGr+ZxNBm6DJTUQ8Xf057nHR8BhB5sfwmzrDPup5wChP/nuOVAfU6wg==} - engines: {node: '>=14'} - dev: false - - /docusaurus-preset-openapi/0.6.3_bitjua5s3tgsymg7hzgopoaz4a: - resolution: {integrity: sha512-zAkQE9SrA4WannHjLhO6PX8QaSqKVATDjFF/ZEj+0jmCikoBzyvsvh+qSGHszAb0Gkg/vKrAXYYBs/Iq1a4O7Q==} - engines: {node: '>=14'} - peerDependencies: - react: ^16.8.4 || ^17.0.0 - react-dom: ^16.8.4 || ^17.0.0 - dependencies: - docusaurus-plugin-openapi: 0.6.3_zneentkx4scexj4pzosurqq55y - docusaurus-plugin-proxy: 0.6.3 - docusaurus-theme-openapi: 0.6.3_bitjua5s3tgsymg7hzgopoaz4a - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - transitivePeerDependencies: - - '@docusaurus/types' - - '@swc/core' - - debug - - esbuild - - react-native - - redux - - supports-color - - uglify-js - - webpack-cli - dev: false - - /docusaurus-theme-openapi/0.6.3_bitjua5s3tgsymg7hzgopoaz4a: - resolution: {integrity: sha512-9cFFbfP2arRHxs/HgSX4phbfpwawN1sxY8H8REZYiqwzEpirNE++qkz507K3bYz7WfOqYwYJAITkPtnPaKzjag==} - engines: {node: '>=14'} - peerDependencies: - react: ^16.8.4 || ^17.0.0 - react-dom: ^16.8.4 || ^17.0.0 - dependencies: - '@mdx-js/react': 1.6.22_react@17.0.2 - '@monaco-editor/react': 4.4.6_mdctwmt4gahkxlfxf2gmcvzx3a - '@reduxjs/toolkit': 1.9.0_csuupx2mfbspdz76fkecdgkise - buffer: 6.0.3 - clsx: 1.2.1 - crypto-js: 4.1.1 - docusaurus-plugin-openapi: 0.6.3_zneentkx4scexj4pzosurqq55y - immer: 9.0.16 - lodash: 4.17.21 - monaco-editor: 0.31.1 - postman-code-generators: 1.2.2 - postman-collection: 4.1.5 - prism-react-renderer: 1.3.5_react@17.0.2 - process: 0.11.10 - react: 17.0.2 - react-dom: 17.0.2_react@17.0.2 - react-magic-dropzone: 1.0.1 - react-redux: 7.2.9_sfoxds7t5ydpegc3knd667wn6m - redux-devtools-extension: 2.13.9_redux@4.2.0 - webpack: 5.75.0 - transitivePeerDependencies: - - '@docusaurus/types' - - '@swc/core' - - debug - - esbuild - - react-native - - redux - - supports-color - - uglify-js - - webpack-cli - dev: false - /dom-converter/0.2.0: resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} dependencies: