2
0

(api) Add CRUD typebot endpoints

Closes #320, closes #696
This commit is contained in:
Baptiste Arnaud
2023-08-17 09:39:11 +02:00
parent 019f72ac7e
commit 454d320c6b
78 changed files with 25014 additions and 1073 deletions

View File

@@ -1,7 +1,11 @@
import { TextInput } from '@/components/inputs'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
import { FormLabel, Stack } from '@chakra-ui/react'
import { EmailInputOptions, Variable } from '@typebot.io/schemas'
import {
EmailInputOptions,
Variable,
invalidEmailDefaultRetryMessage,
} from '@typebot.io/schemas'
import React from 'react'
type Props = {
@@ -33,7 +37,9 @@ export const EmailInputSettings = ({ options, onOptionsChange }: Props) => {
/>
<TextInput
label="Retry message:"
defaultValue={options.retryMessageContent}
defaultValue={
options.retryMessageContent ?? invalidEmailDefaultRetryMessage
}
onChange={handleRetryMessageChange}
/>
<Stack>

View File

@@ -1,7 +1,11 @@
import test, { expect } from '@playwright/test'
import { createTypebots } from '@typebot.io/lib/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from '@typebot.io/lib/playwright/databaseHelpers'
import { defaultEmailInputOptions, InputBlockType } from '@typebot.io/schemas'
import {
defaultEmailInputOptions,
InputBlockType,
invalidEmailDefaultRetryMessage,
} from '@typebot.io/schemas'
import { createId } from '@paralleldrive/cuid2'
test.describe('Email input block', () => {
@@ -35,7 +39,7 @@ test.describe('Email input block', () => {
await expect(page.locator('text=Your email...')).toBeVisible()
await page.getByLabel('Button label:').fill('Go')
await page.fill(
`input[value="${defaultEmailInputOptions.retryMessageContent}"]`,
`input[value="${invalidEmailDefaultRetryMessage}"]`,
'Try again bro'
)

View File

@@ -1,15 +1,13 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { MakeComBlock } from '@typebot.io/schemas'
import { byId, isNotDefined } from '@typebot.io/lib'
import { isNotDefined } from '@typebot.io/lib'
type Props = {
block: MakeComBlock
}
export const MakeComContent = ({ block }: Props) => {
const { webhooks } = useTypebot()
const webhook = block.options.webhook ?? webhooks.find(byId(block.webhookId))
const webhook = block.options.webhook
if (isNotDefined(webhook?.body))
return <Text color="gray.500">Configure...</Text>

View File

@@ -1,9 +1,7 @@
import { Alert, AlertIcon, Button, Link, Stack, Text } from '@chakra-ui/react'
import { ExternalLinkIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { MakeComBlock, Webhook, WebhookOptions } from '@typebot.io/schemas'
import React, { useCallback, useEffect, useState } from 'react'
import { byId } from '@typebot.io/lib'
import React from 'react'
import { WebhookAdvancedConfigForm } from '../../webhook/components/WebhookAdvancedConfigForm'
type Props = {
@@ -12,45 +10,18 @@ type Props = {
}
export const MakeComSettings = ({
block: { webhookId, id: blockId, options },
block: { id: blockId, options },
onOptionsChange,
}: Props) => {
const { webhooks, updateWebhook } = useTypebot()
const webhook = webhooks.find(byId(webhookId))
const [localWebhook, _setLocalWebhook] = useState(webhook)
const setLocalWebhook = useCallback(
async (newLocalWebhook: Webhook) => {
if (options.webhook) {
onOptionsChange({
...options,
webhook: newLocalWebhook,
})
return
}
_setLocalWebhook(newLocalWebhook)
await updateWebhook(newLocalWebhook.id, newLocalWebhook)
},
[onOptionsChange, options, updateWebhook]
)
useEffect(() => {
if (
!localWebhook ||
localWebhook.url ||
!webhook?.url ||
webhook.url === localWebhook.url ||
options.webhook
)
return
setLocalWebhook({
...localWebhook,
url: webhook?.url,
const setLocalWebhook = async (newLocalWebhook: Webhook) => {
if (!options.webhook) return
onOptionsChange({
...options,
webhook: newLocalWebhook,
})
}, [webhook, localWebhook, setLocalWebhook, options.webhook])
}
const url = options.webhook?.url ?? localWebhook?.url
const url = options.webhook?.url
return (
<Stack spacing={4}>
@@ -72,10 +43,10 @@ export const MakeComSettings = ({
</Stack>
)}
</Alert>
{(localWebhook || options.webhook) && (
{options.webhook && (
<WebhookAdvancedConfigForm
blockId={blockId}
webhook={(options.webhook ?? localWebhook) as Webhook}
webhook={options.webhook as Webhook}
options={options}
onWebhookChange={setLocalWebhook}
onOptionsChange={onOptionsChange}

View File

@@ -1,15 +1,13 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { PabblyConnectBlock } from '@typebot.io/schemas'
import { byId, isNotDefined } from '@typebot.io/lib'
import { isNotDefined } from '@typebot.io/lib'
type Props = {
block: PabblyConnectBlock
}
export const PabblyConnectContent = ({ block }: Props) => {
const { webhooks } = useTypebot()
const webhook = block.options.webhook ?? webhooks.find(byId(block.webhookId))
const webhook = block.options.webhook
if (isNotDefined(webhook?.body))
return <Text color="gray.500">Configure...</Text>

View File

@@ -1,13 +1,11 @@
import { Alert, AlertIcon, Button, Link, Stack, Text } from '@chakra-ui/react'
import { ExternalLinkIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import {
PabblyConnectBlock,
Webhook,
WebhookOptions,
} from '@typebot.io/schemas'
import React, { useState } from 'react'
import { byId } from '@typebot.io/lib'
import React from 'react'
import { WebhookAdvancedConfigForm } from '../../webhook/components/WebhookAdvancedConfigForm'
import { TextInput } from '@/components/inputs'
@@ -17,35 +15,23 @@ type Props = {
}
export const PabblyConnectSettings = ({
block: { webhookId, id: blockId, options },
block: { id: blockId, options },
onOptionsChange,
}: Props) => {
const { webhooks, updateWebhook } = useTypebot()
const [localWebhook, _setLocalWebhook] = useState(
webhooks.find(byId(webhookId))
)
const setLocalWebhook = async (newLocalWebhook: Webhook) => {
if (options.webhook) {
onOptionsChange({
...options,
webhook: newLocalWebhook,
})
return
}
_setLocalWebhook(newLocalWebhook)
await updateWebhook(newLocalWebhook.id, newLocalWebhook)
if (!options.webhook) return
onOptionsChange({
...options,
webhook: newLocalWebhook,
})
}
const handleUrlChange = (url: string) =>
localWebhook &&
setLocalWebhook({
...localWebhook,
url,
})
const updateUrl = (url: string) => {
if (!options.webhook) return
onOptionsChange({ ...options, webhook: { ...options.webhook, url } })
}
const url = options.webhook?.url ?? localWebhook?.url
const url = options.webhook?.url
return (
<Stack spacing={4}>
@@ -70,14 +56,14 @@ export const PabblyConnectSettings = ({
<TextInput
placeholder="Paste webhook URL..."
defaultValue={url ?? ''}
onChange={handleUrlChange}
onChange={updateUrl}
withVariableButton={false}
debounceTimeout={0}
/>
{(localWebhook || options.webhook) && (
{options.webhook && (
<WebhookAdvancedConfigForm
blockId={blockId}
webhook={(options.webhook ?? localWebhook) as Webhook}
webhook={options.webhook as Webhook}
options={options}
onWebhookChange={setLocalWebhook}
onOptionsChange={onOptionsChange}

View File

@@ -47,7 +47,7 @@ export const WebhookAdvancedConfigForm = ({
onWebhookChange,
onOptionsChange,
}: Props) => {
const { typebot, save, updateWebhook } = useTypebot()
const { typebot, save } = useTypebot()
const [isTestResponseLoading, setIsTestResponseLoading] = useState(false)
const [testResponse, setTestResponse] = useState<string>()
const [responseKeys, setResponseKeys] = useState<string[]>([])
@@ -80,8 +80,7 @@ export const WebhookAdvancedConfigForm = ({
const executeTestRequest = async () => {
if (!typebot) return
setIsTestResponseLoading(true)
if (!options.webhook)
await Promise.all([updateWebhook(webhook.id, webhook), save()])
if (!options.webhook) await save()
else await save()
const { data, error } = await executeWebhook(
typebot.id,

View File

@@ -1,17 +1,15 @@
import { Stack, Text } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { WebhookBlock } from '@typebot.io/schemas'
import { byId } from '@typebot.io/lib'
import { SetVariableLabel } from '@/components/SetVariableLabel'
type Props = {
block: WebhookBlock
}
export const WebhookContent = ({ block: { options, webhookId } }: Props) => {
export const WebhookContent = ({ block: { options } }: Props) => {
const { typebot } = useTypebot()
const { webhooks } = useTypebot()
const webhook = options.webhook ?? webhooks.find(byId(webhookId))
const webhook = options.webhook
if (!webhook?.url) return <Text color="gray.500">Configure...</Text>
return (

View File

@@ -1,8 +1,6 @@
import React, { useState } from 'react'
import React from 'react'
import { Spinner, Stack } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { WebhookOptions, Webhook, WebhookBlock } from '@typebot.io/schemas'
import { byId } from '@typebot.io/lib'
import { TextInput } from '@/components/inputs'
import { WebhookAdvancedConfigForm } from './WebhookAdvancedConfigForm'
@@ -12,42 +10,32 @@ type Props = {
}
export const WebhookSettings = ({
block: { webhookId, id: blockId, options },
block: { id: blockId, options },
onOptionsChange,
}: Props) => {
const { webhooks, updateWebhook } = useTypebot()
const [localWebhook, _setLocalWebhook] = useState(
webhooks.find(byId(webhookId))
)
const setLocalWebhook = async (newLocalWebhook: Webhook) => {
if (options.webhook) {
onOptionsChange({ ...options, webhook: newLocalWebhook })
return
}
_setLocalWebhook(newLocalWebhook)
await updateWebhook(newLocalWebhook.id, newLocalWebhook)
if (!options.webhook) return
onOptionsChange({ ...options, webhook: newLocalWebhook })
return
}
const updateUrl = (url: string) => {
if (options.webhook)
onOptionsChange({ ...options, webhook: { ...options.webhook, url } })
else if (localWebhook)
setLocalWebhook({ ...localWebhook, url: url ?? undefined })
if (!options.webhook) return
onOptionsChange({ ...options, webhook: { ...options.webhook, url } })
}
if (!localWebhook && !options.webhook) return <Spinner />
if (!options.webhook) return <Spinner />
return (
<Stack spacing={4}>
<TextInput
placeholder="Paste webhook URL..."
defaultValue={options.webhook?.url ?? localWebhook?.url ?? ''}
defaultValue={options.webhook?.url ?? ''}
onChange={updateUrl}
/>
<WebhookAdvancedConfigForm
blockId={blockId}
webhook={(options.webhook ?? localWebhook) as Webhook}
webhook={options.webhook as Webhook}
options={options}
onWebhookChange={setLocalWebhook}
onOptionsChange={onOptionsChange}

View File

@@ -174,7 +174,7 @@ test.describe('API', () => {
expect(data.resultExample).toMatchObject({
message: 'This is a sample result, it has been generated ⬇️',
Welcome: 'Hi!',
Email: 'test@email.com',
Email: 'user@email.com',
Name: 'answer value',
Services: 'Website dev, Content Marketing, Social Media, UI / UX Design',
'Additional information': 'answer value',

View File

@@ -1,15 +1,13 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { ZapierBlock } from '@typebot.io/schemas'
import { byId, isNotDefined } from '@typebot.io/lib'
import { isNotDefined } from '@typebot.io/lib'
type Props = {
block: ZapierBlock
}
export const ZapierContent = ({ block }: Props) => {
const { webhooks } = useTypebot()
const webhook = block.options.webhook ?? webhooks.find(byId(block.webhookId))
const webhook = block.options.webhook
if (isNotDefined(webhook?.body))
return <Text color="gray.500">Configure...</Text>

View File

@@ -1,9 +1,7 @@
import { Alert, AlertIcon, Button, Link, Stack, Text } from '@chakra-ui/react'
import { ExternalLinkIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { Webhook, WebhookOptions, ZapierBlock } from '@typebot.io/schemas'
import React, { useCallback, useEffect, useState } from 'react'
import { byId } from '@typebot.io/lib'
import React from 'react'
import { WebhookAdvancedConfigForm } from '../../webhook/components/WebhookAdvancedConfigForm'
type Props = {
@@ -12,45 +10,19 @@ type Props = {
}
export const ZapierSettings = ({
block: { webhookId, id: blockId, options },
block: { id: blockId, options },
onOptionsChange,
}: Props) => {
const { webhooks, updateWebhook } = useTypebot()
const webhook = webhooks.find(byId(webhookId))
const [localWebhook, _setLocalWebhook] = useState(webhook)
const setLocalWebhook = useCallback(
async (newLocalWebhook: Webhook) => {
if (options.webhook) {
onOptionsChange({
...options,
webhook: newLocalWebhook,
})
return
}
_setLocalWebhook(newLocalWebhook)
await updateWebhook(newLocalWebhook.id, newLocalWebhook)
},
[onOptionsChange, options, updateWebhook]
)
useEffect(() => {
if (
!localWebhook ||
localWebhook.url ||
!webhook?.url ||
webhook.url === localWebhook.url ||
options.webhook
)
return
setLocalWebhook({
...localWebhook,
url: webhook?.url,
const setLocalWebhook = async (newLocalWebhook: Webhook) => {
if (!options.webhook) return
onOptionsChange({
...options,
webhook: newLocalWebhook,
})
}, [webhook, localWebhook, setLocalWebhook, options.webhook])
return
}
const url = options.webhook?.url ?? localWebhook?.url
const url = options.webhook?.url
return (
<Stack spacing={4}>
@@ -72,10 +44,10 @@ export const ZapierSettings = ({
</Stack>
)}
</Alert>
{(localWebhook || options.webhook) && (
{options.webhook && (
<WebhookAdvancedConfigForm
blockId={blockId}
webhook={(options.webhook ?? localWebhook) as Webhook}
webhook={options.webhook as Webhook}
options={options}
onWebhookChange={setLocalWebhook}
onOptionsChange={onOptionsChange}

View File

@@ -33,7 +33,7 @@ const Expression = ({
case 'Custom':
case undefined:
return (
<Text>
<Text as="span">
{variableName} = {options.expressionToEvaluate}
</Text>
)
@@ -48,7 +48,7 @@ const Expression = ({
byId(options.mapListItemParams?.targetListVariableId)
)
return (
<Text>
<Text as="span">
{variableName} = item in ${targetListVariable?.name} with same index
as ${baseItemVariable?.name} in ${baseListVariable?.name}
</Text>
@@ -64,7 +64,7 @@ const Expression = ({
case 'Moment of the day':
case 'Yesterday': {
return (
<Text>
<Text as="span">
{variableName} = <Tag colorScheme="purple">System.{options.type}</Tag>
</Text>
)

View File

@@ -0,0 +1,46 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { collaboratorSchema } from '@typebot.io/schemas/features/collaborators'
import { isReadTypebotForbidden } from '@/features/typebot/helpers/isReadTypebotForbidden'
export const getCollaborators = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/collaborators',
protect: true,
summary: 'Get collaborators',
tags: ['Collaborators'],
},
})
.input(
z.object({
typebotId: z.string(),
})
)
.output(
z.object({
collaborators: z.array(collaboratorSchema),
})
)
.query(async ({ input: { typebotId }, ctx: { user } }) => {
const existingTypebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
},
include: {
collaborators: true,
},
})
if (
!existingTypebot?.id ||
(await isReadTypebotForbidden(existingTypebot, user))
)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
return {
collaborators: existingTypebot.collaborators,
}
})

View File

@@ -0,0 +1,6 @@
import { router } from '@/helpers/server/trpc'
import { getCollaborators } from './getCollaborators'
export const collaboratorsRouter = router({
getCollaborators: getCollaborators,
})

View File

@@ -7,6 +7,7 @@ import {
Typebot,
} from '@typebot.io/schemas'
// TODO: remove
export type NewTypebotProps = Omit<
Typebot,
| 'createdAt'
@@ -56,12 +57,6 @@ export const parseNewTypebot = ({
variables: [],
selectedThemeTemplateId: null,
theme: defaultTheme,
settings: {
...defaultSettings,
general: {
...defaultSettings.general,
isBrandingEnabled,
},
},
settings: defaultSettings({ isBrandingEnabled }),
}
}

View File

@@ -1,18 +0,0 @@
import { Typebot } from '@typebot.io/schemas'
import { sendRequest } from '@typebot.io/lib'
export const createTypebotQuery = async ({
folderId,
workspaceId,
}: Pick<Typebot, 'folderId' | 'workspaceId'>) => {
const typebot = {
folderId,
name: 'My typebot',
workspaceId,
}
return sendRequest<Typebot>({
url: `/api/typebots`,
method: 'POST',
body: typebot,
})
}

View File

@@ -1,7 +0,0 @@
import { sendRequest } from '@typebot.io/lib'
export const deleteTypebotQuery = async (id: string) =>
sendRequest({
url: `/api/typebots/${id}`,
method: 'DELETE',
})

View File

@@ -1,8 +0,0 @@
import { Typebot } from '@typebot.io/schemas'
import { sendRequest } from '@typebot.io/lib'
export const getTypebotQuery = (typebotId: string) =>
sendRequest<{ typebot: Typebot }>({
url: `/api/typebots/${typebotId}`,
method: 'GET',
})

View File

@@ -1,174 +0,0 @@
import { createId } from '@paralleldrive/cuid2'
import { Plan, Prisma } from '@typebot.io/prisma'
import {
ChoiceInputBlock,
ConditionBlock,
LogicBlockType,
Typebot,
} from '@typebot.io/schemas'
import { JumpBlock } from '@typebot.io/schemas/features/blocks/logic/jump'
import {
blockHasItems,
isDefined,
isWebhookBlock,
sendRequest,
} from '@typebot.io/lib'
import { duplicateWebhookQuery } from '@/features/blocks/integrations/webhook/queries/duplicateWebhookQuery'
export const importTypebotQuery = async (typebot: Typebot, userPlan: Plan) => {
const { typebot: newTypebot, webhookIdsMapping } = duplicateTypebot(
typebot,
userPlan
)
const { data, error } = await sendRequest<Typebot>({
url: `/api/typebots`,
method: 'POST',
body: newTypebot,
})
if (!data) return { data, error }
const webhookBlocks = typebot.groups
.flatMap((b) => b.blocks)
.filter(isWebhookBlock)
.filter((block) => block.webhookId)
await Promise.all(
webhookBlocks.map((webhookBlock) =>
duplicateWebhookQuery({
existingIds: {
typebotId: typebot.id,
webhookId: webhookBlock.webhookId as string,
},
newIds: {
typebotId: newTypebot.id,
webhookId: webhookIdsMapping.get(
webhookBlock.webhookId as string
) as string,
},
})
)
)
return { data, error }
}
const duplicateTypebot = (
typebot: Typebot,
userPlan: Plan
): {
typebot: Omit<Prisma.TypebotUncheckedCreateInput, 'id'> & { id: string }
webhookIdsMapping: Map<string, string>
} => {
const groupIdsMapping = generateOldNewIdsMapping(typebot.groups)
const blockIdsMapping = generateOldNewIdsMapping(
typebot.groups.flatMap((group) => group.blocks)
)
const edgeIdsMapping = generateOldNewIdsMapping(typebot.edges)
const webhookIdsMapping = generateOldNewIdsMapping(
typebot.groups
.flatMap((group) => group.blocks)
.filter(isWebhookBlock)
.map((block) => ({
id: block.webhookId as string,
}))
)
const id = createId()
return {
typebot: {
...typebot,
id,
name: `${typebot.name} copy`,
publicId: null,
customDomain: null,
groups: typebot.groups.map((group) => ({
...group,
id: groupIdsMapping.get(group.id) as string,
blocks: group.blocks.map((block) => {
const newIds = {
id: blockIdsMapping.get(block.id) as string,
groupId: groupIdsMapping.get(block.groupId) as string,
outgoingEdgeId: block.outgoingEdgeId
? edgeIdsMapping.get(block.outgoingEdgeId)
: undefined,
}
if (
block.type === LogicBlockType.TYPEBOT_LINK &&
block.options.typebotId === 'current' &&
isDefined(block.options.groupId)
)
return {
...block,
...newIds,
options: {
...block.options,
groupId: groupIdsMapping.get(block.options.groupId as string),
},
}
if (block.type === LogicBlockType.JUMP)
return {
...block,
...newIds,
options: {
...block.options,
groupId: groupIdsMapping.get(block.options.groupId as string),
blockId: blockIdsMapping.get(block.options.blockId as string),
} satisfies JumpBlock['options'],
}
if (blockHasItems(block))
return {
...block,
...newIds,
items: block.items.map((item) => ({
...item,
outgoingEdgeId: item.outgoingEdgeId
? (edgeIdsMapping.get(item.outgoingEdgeId) as string)
: undefined,
})),
} as ChoiceInputBlock | ConditionBlock
if (isWebhookBlock(block) && block.webhookId) {
return {
...block,
...newIds,
webhookId: webhookIdsMapping.get(block.webhookId) as string,
}
}
return {
...block,
...newIds,
}
}),
})),
edges: typebot.edges.map((edge) => ({
...edge,
id: edgeIdsMapping.get(edge.id) as string,
from: {
...edge.from,
blockId: blockIdsMapping.get(edge.from.blockId) as string,
groupId: groupIdsMapping.get(edge.from.groupId) as string,
},
to: {
...edge.to,
blockId: edge.to.blockId
? (blockIdsMapping.get(edge.to.blockId) as string)
: undefined,
groupId: groupIdsMapping.get(edge.to.groupId) as string,
},
})),
settings:
typebot.settings.general.isBrandingEnabled === false &&
userPlan === Plan.FREE
? {
...typebot.settings,
general: { ...typebot.settings.general, isBrandingEnabled: true },
}
: typebot.settings,
createdAt: new Date(),
updatedAt: new Date(),
resultsTablePreferences: typebot.resultsTablePreferences ?? undefined,
},
webhookIdsMapping,
}
}
const generateOldNewIdsMapping = (itemWithId: { id: string }[]) => {
const idsMapping: Map<string, string> = new Map()
itemWithId.forEach((item) => idsMapping.set(item.id, createId()))
return idsMapping
}

View File

@@ -52,9 +52,11 @@ export const TypebotHeader = () => {
}, 1000)
const { isOpen, onOpen } = useDisclosure()
const handleNameSubmit = (name: string) => updateTypebot({ name })
const handleNameSubmit = (name: string) =>
updateTypebot({ updates: { name } })
const handleChangeIcon = (icon: string) => updateTypebot({ icon })
const handleChangeIcon = (icon: string) =>
updateTypebot({ updates: { icon } })
const handlePreviewClick = async () => {
setStartPreviewAtGroup(undefined)

View File

@@ -5,11 +5,6 @@ import {
createTypebots,
importTypebotInDatabase,
} from '@typebot.io/lib/playwright/databaseActions'
import {
waitForSuccessfulDeleteRequest,
waitForSuccessfulPostRequest,
waitForSuccessfulPutRequest,
} from '@typebot.io/lib/playwright/testHelpers'
import { parseDefaultGroupWithBlock } from '@typebot.io/lib/playwright/databaseHelpers'
import { getTestAsset } from '@/test/utils/playwright'
@@ -236,25 +231,13 @@ test('Published typebot menu should work', async ({ page }) => {
await expect(page.locator("text='Start'")).toBeVisible()
await expect(page.locator('button >> text="Published"')).toBeVisible()
await page.click('[aria-label="Show published typebot menu"]')
await Promise.all([
waitForSuccessfulPutRequest(page),
page.click('text="Close typebot to new responses"'),
])
await page.click('text="Close typebot to new responses"')
await expect(page.locator('button >> text="Closed"')).toBeDisabled()
await page.click('[aria-label="Show published typebot menu"]')
await Promise.all([
waitForSuccessfulPutRequest(page),
page.click('text="Reopen typebot to new responses"'),
])
await page.click('text="Reopen typebot to new responses"')
await expect(page.locator('button >> text="Published"')).toBeDisabled()
await page.click('[aria-label="Show published typebot menu"]')
await Promise.all([
waitForSuccessfulDeleteRequest(page),
page.click('button >> text="Unpublish typebot"'),
])
await Promise.all([
waitForSuccessfulPostRequest(page),
page.click('button >> text="Publish"'),
])
await page.click('button >> text="Unpublish typebot"')
await page.click('button >> text="Publish"')
await expect(page.locator('button >> text="Published"')).toBeVisible()
})

View File

@@ -1,9 +1,4 @@
import {
LogicBlockType,
PublicTypebot,
Typebot,
Webhook,
} from '@typebot.io/schemas'
import { LogicBlockType, PublicTypebot, Typebot } from '@typebot.io/schemas'
import { Router, useRouter } from 'next/router'
import {
createContext,
@@ -12,7 +7,6 @@ import {
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { isDefined, omit } from '@typebot.io/lib'
import { edgesAction, EdgesActions } from './typebotActions/edges'
@@ -22,21 +16,11 @@ import { blocksAction, BlocksActions } from './typebotActions/blocks'
import { variablesAction, VariablesActions } from './typebotActions/variables'
import { dequal } from 'dequal'
import { useToast } from '@/hooks/useToast'
import { useTypebotQuery } from '@/hooks/useTypebotQuery'
import { useUndo } from '../hooks/useUndo'
import { updateTypebotQuery } from '../queries/updateTypebotQuery'
import { updateWebhookQuery } from '@/features/blocks/integrations/webhook/queries/updateWebhookQuery'
import { useAutoSave } from '@/hooks/useAutoSave'
import { createWebhookQuery } from '@/features/blocks/integrations/webhook/queries/createWebhookQuery'
import { duplicateWebhookQuery } from '@/features/blocks/integrations/webhook/queries/duplicateWebhookQuery'
import { parseDefaultPublicId } from '@/features/publish/helpers/parseDefaultPublicId'
import { createPublishedTypebotQuery } from '@/features/publish/queries/createPublishedTypebotQuery'
import { deletePublishedTypebotQuery } from '@/features/publish/queries/deletePublishedTypebotQuery'
import { updatePublishedTypebotQuery } from '@/features/publish/queries/updatePublishedTypebotQuery'
import { preventUserFromRefreshing } from '@/helpers/preventUserFromRefreshing'
import { areTypebotsEqual } from '@/features/publish/helpers/areTypebotsEqual'
import { isPublished as isPublishedHelper } from '@/features/publish/helpers/isPublished'
import { convertTypebotToPublicTypebot } from '@/features/publish/helpers/convertTypebotToPublicTypebot'
import { convertPublicTypebotToTypebot } from '@/features/publish/helpers/convertPublicTypebotToTypebot'
import { trpc } from '@/lib/trpc'
@@ -66,23 +50,18 @@ const typebotContext = createContext<
typebot?: Typebot
publishedTypebot?: PublicTypebot
linkedTypebots?: Pick<Typebot, 'id' | 'groups' | 'variables' | 'name'>[]
webhooks: Webhook[]
isReadOnly?: boolean
isPublished: boolean
isPublishing: boolean
isSavingLoading: boolean
save: () => Promise<void>
save: () => Promise<Typebot | undefined>
undo: () => void
redo: () => void
canRedo: boolean
canUndo: boolean
updateWebhook: (
webhookId: string,
webhook: Partial<Webhook>
) => Promise<void>
updateTypebot: (updates: UpdateTypebotPayload) => void
publishTypebot: () => void
unpublishTypebot: () => void
updateTypebot: (props: {
updates: UpdateTypebotPayload
save?: boolean
}) => Promise<Typebot | undefined>
restorePublishedTypebot: () => void
} & GroupsActions &
BlocksActions &
@@ -104,15 +83,48 @@ export const TypebotProvider = ({
const { showToast } = useToast()
const {
typebot,
publishedTypebot,
webhooks,
isReadOnly,
data: typebotData,
isLoading: isFetchingTypebot,
mutate,
} = useTypebotQuery({
typebotId,
})
refetch: refetchTypebot,
} = trpc.typebot.getTypebot.useQuery(
{ typebotId: typebotId as string },
{
enabled: isDefined(typebotId),
onError: (error) =>
showToast({
title: 'Error while fetching typebot. Refresh the page.',
description: error.message,
}),
}
)
const { data: publishedTypebotData } =
trpc.typebot.getPublishedTypebot.useQuery(
{ typebotId: typebotId as string },
{
enabled: isDefined(typebotId),
onError: (error) =>
showToast({
title: 'Error while fetching published typebot',
description: error.message,
}),
}
)
const { mutateAsync: updateTypebot, isLoading: isSaving } =
trpc.typebot.updateTypebot.useMutation({
onError: (error) =>
showToast({
title: 'Error while updating typebot',
description: error.message,
}),
onSuccess: () => {
refetchTypebot()
},
})
const typebot = typebotData?.typebot
const publishedTypebot = publishedTypebotData?.publishedTypebot ?? undefined
const [
localTypebot,
@@ -180,53 +192,17 @@ export const TypebotProvider = ({
const typebotToSave = { ...localTypebot, ...updates }
if (dequal(omit(typebot, 'updatedAt'), omit(typebotToSave, 'updatedAt')))
return
setIsSavingLoading(true)
const { data, error } = await updateTypebotQuery(
typebotToSave.id,
typebotToSave
)
if (data?.typebot) setLocalTypebot({ ...data.typebot })
setIsSavingLoading(false)
if (error) {
showToast({ title: error.name, description: error.message })
return
}
mutate({
setLocalTypebot({ ...typebotToSave })
const { typebot: newTypebot } = await updateTypebot({
typebotId: typebotToSave.id,
typebot: typebotToSave,
publishedTypebot,
webhooks: webhooks ?? [],
})
window.removeEventListener('beforeunload', preventUserFromRefreshing)
setLocalTypebot({ ...newTypebot })
return newTypebot
},
[
localTypebot,
mutate,
publishedTypebot,
setLocalTypebot,
showToast,
typebot,
webhooks,
]
[localTypebot, setLocalTypebot, typebot, updateTypebot]
)
const savePublishedTypebot = async (newPublishedTypebot: PublicTypebot) => {
if (!localTypebot) return
setIsPublishing(true)
const { error } = await updatePublishedTypebotQuery(
newPublishedTypebot.id,
newPublishedTypebot,
localTypebot.workspaceId
)
setIsPublishing(false)
if (error)
return showToast({ title: error.name, description: error.message })
mutate({
typebot: localTypebot,
publishedTypebot: newPublishedTypebot,
webhooks: webhooks ?? [],
})
}
useAutoSave(
{
handler: saveTypebot,
@@ -246,12 +222,10 @@ export const TypebotProvider = ({
}
}, [saveTypebot])
const [isSavingLoading, setIsSavingLoading] = useState(false)
const [isPublishing, setIsPublishing] = useState(false)
const isPublished = useMemo(
() =>
isDefined(localTypebot) &&
isDefined(localTypebot.publicId) &&
isDefined(publishedTypebot) &&
isPublishedHelper(localTypebot, publishedTypebot),
[localTypebot, publishedTypebot]
@@ -268,56 +242,18 @@ export const TypebotProvider = ({
}
}, [localTypebot, typebot])
const updateLocalTypebot = (updates: UpdateTypebotPayload) =>
localTypebot && setLocalTypebot({ ...localTypebot, ...updates })
const publishTypebot = async () => {
const updateLocalTypebot = async ({
updates,
save,
}: {
updates: UpdateTypebotPayload
save?: boolean
}) => {
if (!localTypebot) return
const newLocalTypebot = { ...localTypebot }
if (!publishedTypebot || !localTypebot.publicId) {
const newPublicId =
localTypebot.publicId ??
parseDefaultPublicId(localTypebot.name, localTypebot.id)
newLocalTypebot.publicId = newPublicId
await saveTypebot({ publicId: newPublicId })
}
if (publishedTypebot) {
await savePublishedTypebot({
...convertTypebotToPublicTypebot(newLocalTypebot),
id: publishedTypebot.id,
})
} else {
setIsPublishing(true)
const { data, error } = await createPublishedTypebotQuery(
{
...omit(convertTypebotToPublicTypebot(newLocalTypebot), 'id'),
},
localTypebot.workspaceId
)
setIsPublishing(false)
if (error)
return showToast({ title: error.name, description: error.message })
mutate({
typebot: localTypebot,
publishedTypebot: data,
webhooks: webhooks ?? [],
})
}
}
const unpublishTypebot = async () => {
if (!publishedTypebot || !localTypebot) return
setIsPublishing(true)
const { error } = await deletePublishedTypebotQuery({
publishedTypebotId: publishedTypebot.id,
typebotId: localTypebot.id,
})
setIsPublishing(false)
if (error) showToast({ description: error.message })
mutate({
typebot: localTypebot,
webhooks: webhooks ?? [],
})
const newTypebot = { ...localTypebot, ...updates }
setLocalTypebot(newTypebot)
if (save) await saveTypebot(newTypebot)
return newTypebot
}
const restorePublishedTypebot = () => {
@@ -325,64 +261,6 @@ export const TypebotProvider = ({
setLocalTypebot(
convertPublicTypebotToTypebot(publishedTypebot, localTypebot)
)
return saveTypebot()
}
const updateWebhook = useCallback(
async (webhookId: string, updates: Partial<Webhook>) => {
if (!typebot) return
const { data } = await updateWebhookQuery({
typebotId: typebot.id,
webhookId,
data: updates,
})
if (data)
mutate({
typebot,
publishedTypebot,
webhooks: (webhooks ?? []).map((w) =>
w.id === webhookId ? data.webhook : w
),
})
},
[mutate, publishedTypebot, typebot, webhooks]
)
const createWebhook = async (data: Partial<Webhook>) => {
if (!typebot) return
const response = await createWebhookQuery({
typebotId: typebot.id,
data,
})
if (!response.data?.webhook) return
mutate({
typebot,
publishedTypebot,
webhooks: (webhooks ?? []).concat(response.data?.webhook),
})
}
const duplicateWebhook = async (
existingWebhookId: string,
newWebhookId: string
) => {
if (!typebot) return
const newWebhook = await duplicateWebhookQuery({
existingIds: {
typebotId: typebot.id,
webhookId: existingWebhookId,
},
newIds: {
typebotId: typebot.id,
webhookId: newWebhookId,
},
})
if (!newWebhook) return
mutate({
typebot,
publishedTypebot,
webhooks: (webhooks ?? []).concat(newWebhook),
})
}
return (
@@ -391,29 +269,18 @@ export const TypebotProvider = ({
typebot: localTypebot,
publishedTypebot,
linkedTypebots: linkedTypebotsData?.typebots ?? [],
webhooks: webhooks ?? [],
isReadOnly,
isSavingLoading,
isReadOnly: typebotData?.isReadOnly,
isSavingLoading: isSaving,
save: saveTypebot,
undo,
redo,
canUndo,
canRedo,
publishTypebot,
unpublishTypebot,
isPublishing,
isPublished,
updateTypebot: updateLocalTypebot,
restorePublishedTypebot,
updateWebhook,
...groupsActions(setLocalTypebot as SetTypebot, {
onWebhookBlockCreated: createWebhook,
onWebhookBlockDuplicated: duplicateWebhook,
}),
...blocksAction(setLocalTypebot as SetTypebot, {
onWebhookBlockCreated: createWebhook,
onWebhookBlockDuplicated: duplicateWebhook,
}),
...groupsActions(setLocalTypebot as SetTypebot),
...blocksAction(setLocalTypebot as SetTypebot),
...variablesAction(setLocalTypebot as SetTypebot),
...edgesAction(setLocalTypebot as SetTypebot),
...itemsAction(setLocalTypebot as SetTypebot),

View File

@@ -37,10 +37,7 @@ export type WebhookCallBacks = {
) => void
}
export const blocksAction = (
setTypebot: SetTypebot,
{ onWebhookBlockCreated, onWebhookBlockDuplicated }: WebhookCallBacks
): BlocksActions => ({
export const blocksAction = (setTypebot: SetTypebot): BlocksActions => ({
createBlock: (
groupId: string,
block: DraggableBlock | DraggableBlockType,
@@ -48,13 +45,7 @@ export const blocksAction = (
) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
createBlockDraft(
typebot,
block,
groupId,
indices,
onWebhookBlockCreated
)
createBlockDraft(typebot, block, groupId, indices)
})
),
updateBlock: (
@@ -74,10 +65,7 @@ export const blocksAction = (
const blocks = typebot.groups[groupIndex].blocks
if (blockIndex === blocks.length - 1 && block.outgoingEdgeId)
deleteEdgeDraft(typebot, block.outgoingEdgeId as string)
const newBlock = duplicateBlockDraft(block.groupId)(
block,
onWebhookBlockDuplicated
)
const newBlock = duplicateBlockDraft(block.groupId)(block)
typebot.groups[groupIndex].blocks.splice(blockIndex + 1, 0, newBlock)
})
),
@@ -105,8 +93,7 @@ export const createBlockDraft = (
typebot: Draft<Typebot>,
block: DraggableBlock | DraggableBlockType,
groupId: string,
{ groupIndex, blockIndex }: BlockIndices,
onWebhookBlockCreated?: (data: Partial<Webhook>) => void
{ groupIndex, blockIndex }: BlockIndices
) => {
const blocks = typebot.groups[groupIndex].blocks
if (
@@ -116,13 +103,7 @@ export const createBlockDraft = (
)
deleteEdgeDraft(typebot, blocks[blockIndex - 1].outgoingEdgeId as string)
typeof block === 'string'
? createNewBlock(
typebot,
block,
groupId,
{ groupIndex, blockIndex },
onWebhookBlockCreated
)
? createNewBlock(typebot, block, groupId, { groupIndex, blockIndex })
: moveBlockToGroup(typebot, block, groupId, { groupIndex, blockIndex })
removeEmptyGroups(typebot)
}
@@ -174,10 +155,7 @@ const moveBlockToGroup = (
export const duplicateBlockDraft =
(groupId: string) =>
(
block: Block,
onWebhookBlockDuplicated: WebhookCallBacks['onWebhookBlockDuplicated']
): Block => {
(block: Block): Block => {
const blockId = createId()
if (blockHasItems(block))
return {
@@ -189,8 +167,6 @@ export const duplicateBlockDraft =
} as Block
if (isWebhookBlock(block)) {
const newWebhookId = createId()
if (block.webhookId)
onWebhookBlockDuplicated(block.webhookId, newWebhookId)
return {
...block,
groupId,

View File

@@ -11,7 +11,6 @@ import {
deleteGroupDraft,
createBlockDraft,
duplicateBlockDraft,
WebhookCallBacks,
} from './blocks'
import { isEmpty, parseGroupTitle } from '@typebot.io/lib'
import { Coordinates } from '@/features/graph/types'
@@ -29,10 +28,7 @@ export type GroupsActions = {
deleteGroup: (groupIndex: number) => void
}
const groupsActions = (
setTypebot: SetTypebot,
{ onWebhookBlockCreated, onWebhookBlockDuplicated }: WebhookCallBacks
): GroupsActions => ({
const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
createGroup: ({
id,
block,
@@ -52,13 +48,7 @@ const groupsActions = (
blocks: [],
}
typebot.groups.push(newGroup)
createBlockDraft(
typebot,
block,
newGroup.id,
indices,
onWebhookBlockCreated
)
createBlockDraft(typebot, block, newGroup.id, indices)
})
),
updateGroup: (groupIndex: number, updates: Partial<Omit<Group, 'id'>>) =>
@@ -79,9 +69,7 @@ const groupsActions = (
? ''
: `${parseGroupTitle(group.title)} copy`,
id,
blocks: group.blocks.map((block) =>
duplicateBlockDraft(id)(block, onWebhookBlockDuplicated)
),
blocks: group.blocks.map((block) => duplicateBlockDraft(id)(block)),
graphCoordinates: {
x: group.graphCoordinates.x + 200,
y: group.graphCoordinates.y + 100,

View File

@@ -1,9 +0,0 @@
import { Typebot } from '@typebot.io/schemas'
import { sendRequest } from '@typebot.io/lib'
export const updateTypebotQuery = async (id: string, typebot: Typebot) =>
sendRequest<{ typebot: Typebot }>({
url: `/api/typebots/${id}`,
method: 'PUT',
body: typebot,
})

View File

@@ -24,8 +24,8 @@ import { useRouter } from 'next/router'
import React, { useMemo } from 'react'
import { deleteFolderQuery } from '../queries/deleteFolderQuery'
import { useToast } from '@/hooks/useToast'
import { updateFolderQuery } from '../queries/updateFolderQuery'
import { useI18n, useScopedI18n } from '@/locales'
import { updateFolderQuery } from '../queries/updateFolderQuery'
export const FolderButton = ({
folder,

View File

@@ -15,7 +15,6 @@ import { BackButton } from './BackButton'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { useToast } from '@/hooks/useToast'
import { useFolders } from '../hooks/useFolders'
import { patchTypebotQuery } from '../queries/patchTypebotQuery'
import { createFolderQuery } from '../queries/createFolderQuery'
import { CreateBotButton } from './CreateBotButton'
import { CreateFolderButton } from './CreateFolderButton'
@@ -25,6 +24,7 @@ import { TypebotCardOverlay } from './TypebotButtonOverlay'
import { useI18n } from '@/locales'
import { useTypebots } from '@/features/dashboard/hooks/useTypebots'
import { TypebotInDashboard } from '@/features/dashboard/types'
import { trpc } from '@/lib/trpc'
type Props = { folder: DashboardFolder | null }
@@ -65,6 +65,15 @@ export const FolderContent = ({ folder }: Props) => {
},
})
const { mutate: updateTypebot } = trpc.typebot.updateTypebot.useMutation({
onError: (error) => {
showToast({ description: error.message })
},
onSuccess: () => {
refetchTypebots()
},
})
const {
typebots,
isLoading: isTypebotLoading,
@@ -81,11 +90,12 @@ export const FolderContent = ({ folder }: Props) => {
const moveTypebotToFolder = async (typebotId: string, folderId: string) => {
if (!typebots) return
const { error } = await patchTypebotQuery(typebotId, {
folderId: folderId === 'root' ? null : folderId,
updateTypebot({
typebotId,
typebot: {
folderId: folderId === 'root' ? null : folderId,
},
})
if (error) showToast({ description: error.message })
refetchTypebots()
}
const handleCreateFolder = async () => {

View File

@@ -18,18 +18,13 @@ import { ConfirmModal } from '@/components/ConfirmModal'
import { GripIcon } from '@/components/icons'
import { useTypebotDnd } from '../TypebotDndProvider'
import { useDebounce } from 'use-debounce'
import { Plan } from '@typebot.io/prisma'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { useToast } from '@/hooks/useToast'
import { MoreButton } from './MoreButton'
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
import { deletePublishedTypebotQuery } from '@/features/publish/queries/deletePublishedTypebotQuery'
import { useScopedI18n } from '@/locales'
import { deleteTypebotQuery } from '@/features/dashboard/queries/deleteTypebotQuery'
import { getTypebotQuery } from '@/features/dashboard/queries/getTypebotQuery'
import { importTypebotQuery } from '@/features/dashboard/queries/importTypebotQuery'
import { TypebotInDashboard } from '@/features/dashboard/types'
import { isMobile } from '@/helpers/isMobile'
import { trpc, trpcVanilla } from '@/lib/trpc'
type Props = {
typebot: TypebotInDashboard
@@ -46,7 +41,6 @@ export const TypebotButton = ({
}: Props) => {
const scopedT = useScopedI18n('folders.typebotButton')
const router = useRouter()
const { workspace } = useWorkspace()
const { draggedTypebot } = useTypebotDnd()
const [draggedTypebotDebounced] = useDebounce(draggedTypebot, 200)
const {
@@ -57,6 +51,34 @@ export const TypebotButton = ({
const { showToast } = useToast()
const { mutate: createTypebot } = trpc.typebot.createTypebot.useMutation({
onError: (error) => {
showToast({ description: error.message })
},
onSuccess: ({ typebot }) => {
router.push(`/typebots/${typebot.id}/edit`)
},
})
const { mutate: deleteTypebot } = trpc.typebot.deleteTypebot.useMutation({
onError: (error) => {
showToast({ description: error.message })
},
onSuccess: () => {
onTypebotUpdated()
},
})
const { mutate: unpublishTypebot } =
trpc.typebot.unpublishTypebot.useMutation({
onError: (error) => {
showToast({ description: error.message })
},
onSuccess: () => {
onTypebotUpdated()
},
})
const handleTypebotClick = () => {
if (draggedTypebotDebounced) return
router.push(
@@ -68,28 +90,27 @@ export const TypebotButton = ({
const handleDeleteTypebotClick = async () => {
if (isReadOnly) return
const { error } = await deleteTypebotQuery(typebot.id)
if (error)
return showToast({
description: error.message,
})
onTypebotUpdated()
deleteTypebot({
typebotId: typebot.id,
})
}
const handleDuplicateClick = async (e: React.MouseEvent) => {
e.stopPropagation()
const { data } = await getTypebotQuery(typebot.id)
const typebotToDuplicate = data?.typebot
if (!typebotToDuplicate) return
const { data: createdTypebot, error } = await importTypebotQuery(
data.typebot,
workspace?.plan ?? Plan.FREE
)
if (error)
return showToast({
description: error.message,
const { typebot: typebotToDuplicate } =
await trpcVanilla.typebot.getTypebot.query({
typebotId: typebot.id,
})
if (createdTypebot) router.push(`/typebots/${createdTypebot?.id}/edit`)
if (!typebotToDuplicate) return
createTypebot({
workspaceId: typebotToDuplicate.workspaceId,
typebot: {
...typebotToDuplicate,
customDomain: undefined,
publicId: undefined,
name: duplicateName(typebotToDuplicate.name),
},
})
}
const handleDeleteClick = (e: React.MouseEvent) => {
@@ -100,12 +121,7 @@ export const TypebotButton = ({
const handleUnpublishClick = async (e: React.MouseEvent) => {
e.stopPropagation()
if (!typebot.publishedTypebotId) return
const { error } = await deletePublishedTypebotQuery({
publishedTypebotId: typebot.publishedTypebotId,
typebotId: typebot.id,
})
if (error) showToast({ description: error.message })
else onTypebotUpdated()
unpublishTypebot({ typebotId: typebot.id })
}
return (
@@ -205,3 +221,10 @@ export const TypebotButton = ({
</Button>
)
}
const duplicateName = (name: string | `${string} (${number})`) => {
const match = name.match(/^(.*) \((\d+)\)$/)
if (!match) return `${name} (1)`
const [, nameWithoutNumber, number] = match
return `${nameWithoutNumber} (${Number(number) + 1})`
}

View File

@@ -1,12 +0,0 @@
import { Typebot } from '@typebot.io/prisma'
import { sendRequest } from '@typebot.io/lib'
export const patchTypebotQuery = async (
id: string,
typebot: Partial<Typebot>
) =>
sendRequest<{ typebot: Typebot }>({
url: `/api/typebots/${id}`,
method: 'PATCH',
body: typebot,
})

View File

@@ -117,9 +117,9 @@ export const ItemNode = ({
source={{
groupId: typebot.groups[indices.groupIndex].id,
blockId:
typebot.groups[indices.groupIndex].blocks[
typebot.groups[indices.groupIndex]?.blocks[
indices.blockIndex
].id,
]?.id,
itemId: item.id,
}}
pos="absolute"

View File

@@ -28,6 +28,9 @@ import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
import { isFreePlan } from '@/features/billing/helpers/isFreePlan'
import { parseTimeSince } from '@/helpers/parseTimeSince'
import { useI18n } from '@/locales'
import { trpc } from '@/lib/trpc'
import { useToast } from '@/hooks/useToast'
import { parseDefaultPublicId } from '../helpers/parseDefaultPublicId'
export const PublishButton = (props: ButtonProps) => {
const t = useI18n()
@@ -36,36 +39,83 @@ export const PublishButton = (props: ButtonProps) => {
const { push, query } = useRouter()
const { isOpen, onOpen, onClose } = useDisclosure()
const {
isPublishing,
isPublished,
publishTypebot,
publishedTypebot,
restorePublishedTypebot,
typebot,
isSavingLoading,
updateTypebot,
unpublishTypebot,
save,
} = useTypebot()
const { showToast } = useToast()
const {
typebot: {
getPublishedTypebot: { refetch: refetchPublishedTypebot },
},
} = trpc.useContext()
const { mutate: publishTypebotMutate, isLoading: isPublishing } =
trpc.typebot.publishTypebot.useMutation({
onError: (error) =>
showToast({
title: 'Error while publishing typebot',
description: error.message,
}),
onSuccess: () => {
if (!publishedTypebot) push(`/typebots/${query.typebotId}/share`)
refetchPublishedTypebot()
},
})
const { mutate: unpublishTypebotMutate, isLoading: isUnpublishing } =
trpc.typebot.unpublishTypebot.useMutation({
onError: (error) =>
showToast({
title: 'Error while unpublishing typebot',
description: error.message,
}),
onSuccess: () => {
if (!publishedTypebot) push(`/typebots/${query.typebotId}/share`)
refetchPublishedTypebot()
},
})
const hasInputFile = typebot?.groups
.flatMap((g) => g.blocks)
.some((b) => b.type === InputBlockType.FILE)
const handlePublishClick = () => {
const handlePublishClick = async () => {
if (!typebot?.id) return
if (isFreePlan(workspace) && hasInputFile) return onOpen()
publishTypebot()
if (!publishedTypebot) push(`/typebots/${query.typebotId}/share`)
if (!typebot.publicId) {
await updateTypebot({
updates: {
publicId: parseDefaultPublicId(typebot.name, typebot.id),
},
save: true,
})
} else await save()
publishTypebotMutate({
typebotId: typebot.id,
})
}
const unpublishTypebot = async () => {
if (!typebot?.id) return
if (typebot.isClosed)
await updateTypebot({ updates: { isClosed: false }, save: true })
unpublishTypebotMutate({
typebotId: typebot?.id,
})
}
const closeTypebot = async () => {
updateTypebot({ isClosed: true })
await save()
await updateTypebot({ updates: { isClosed: true }, save: true })
}
const openTypebot = async () => {
updateTypebot({ isClosed: false })
await save()
await updateTypebot({ updates: { isClosed: false }, save: true })
}
return (
@@ -99,8 +149,8 @@ export const PublishButton = (props: ButtonProps) => {
>
<Button
colorScheme="blue"
isLoading={isPublishing || isSavingLoading}
isDisabled={isPublished}
isLoading={isPublishing || isUnpublishing}
isDisabled={isPublished || isSavingLoading}
onClick={handlePublishClick}
borderRightRadius={publishedTypebot ? 0 : undefined}
{...props}

View File

@@ -33,7 +33,7 @@ export const SharePage = () => {
const { showToast } = useToast()
const handlePublicIdChange = async (publicId: string) => {
updateTypebot({ publicId })
updateTypebot({ updates: { publicId } })
}
const publicId = typebot
@@ -50,7 +50,7 @@ export const SharePage = () => {
}
const handleCustomDomainChange = (customDomain: string | null) =>
updateTypebot({ customDomain })
updateTypebot({ updates: { customDomain } })
const checkIfPathnameIsValid = (pathname: string) => {
const isCorrectlyFormatted =

View File

@@ -1,12 +0,0 @@
import { PublicTypebot } from '@typebot.io/schemas'
import { sendRequest } from '@typebot.io/lib'
export const createPublishedTypebotQuery = async (
typebot: Omit<PublicTypebot, 'id'>,
workspaceId: string
) =>
sendRequest<PublicTypebot>({
url: `/api/publicTypebots?workspaceId=${workspaceId}`,
method: 'POST',
body: typebot,
})

View File

@@ -1,13 +0,0 @@
import { sendRequest } from '@typebot.io/lib'
export const deletePublishedTypebotQuery = ({
publishedTypebotId,
typebotId,
}: {
publishedTypebotId: string
typebotId: string
}) =>
sendRequest({
method: 'DELETE',
url: `/api/publicTypebots/${publishedTypebotId}?typebotId=${typebotId}`,
})

View File

@@ -1,13 +0,0 @@
import { PublicTypebot } from '@typebot.io/schemas'
import { sendRequest } from '@typebot.io/lib'
export const updatePublishedTypebotQuery = async (
id: string,
typebot: Omit<PublicTypebot, 'id'>,
workspaceId: string
) =>
sendRequest({
url: `/api/publicTypebots/${id}?workspaceId=${workspaceId}`,
method: 'PUT',
body: typebot,
})

View File

@@ -1,10 +1,10 @@
import { getTypebot } from '@/features/typebot/helpers/getTypebot'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { Typebot } from '@typebot.io/schemas'
import { Group } from '@typebot.io/schemas'
import { z } from 'zod'
import { archiveResults } from '@typebot.io/lib/api/helpers/archiveResults'
import prisma from '@/lib/prisma'
import { isWriteTypebotForbidden } from '@/features/typebot/helpers/isWriteTypebotForbidden'
export const deleteResults = authenticatedProcedure
.meta({
@@ -31,18 +31,27 @@ export const deleteResults = authenticatedProcedure
.mutation(async ({ input, ctx: { user } }) => {
const idsArray = input.resultIds?.split(',')
const { typebotId } = input
const typebot = (await getTypebot({
accessLevel: 'write',
typebotId,
user,
select: {
groups: true,
const typebot = await prisma.typebot.findUnique({
where: {
id: typebotId,
},
})) as Pick<Typebot, 'groups'> | null
if (!typebot)
select: {
workspaceId: true,
groups: true,
collaborators: {
select: {
userId: true,
type: true,
},
},
},
})
if (!typebot || (await isWriteTypebotForbidden(typebot, user)))
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const { success } = await archiveResults(prisma)({
typebot,
typebot: {
groups: typebot.groups as Group[],
},
resultsFilter: {
id: (idsArray?.length ?? 0) > 0 ? { in: idsArray } : undefined,
typebotId,

View File

@@ -1,9 +1,9 @@
import { getTypebot } from '@/features/typebot/helpers/getTypebot'
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { ResultWithAnswers, resultWithAnswersSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { isReadTypebotForbidden } from '@/features/typebot/helpers/isReadTypebotForbidden'
export const getResult = authenticatedProcedure
.meta({
@@ -27,12 +27,23 @@ export const getResult = authenticatedProcedure
})
)
.query(async ({ input, ctx: { user } }) => {
const typebot = await getTypebot({
accessLevel: 'read',
user,
typebotId: input.typebotId,
const typebot = await prisma.typebot.findUnique({
where: {
id: input.typebotId,
},
select: {
id: true,
workspaceId: true,
groups: true,
collaborators: {
select: {
userId: true,
type: true,
},
},
},
})
if (!typebot?.id)
if (!typebot || (await isReadTypebotForbidden(typebot, user)))
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const results = (await prisma.result.findMany({
where: {

View File

@@ -1,8 +1,8 @@
import { getTypebot } from '@/features/typebot/helpers/getTypebot'
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { logSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { isReadTypebotForbidden } from '@/features/typebot/helpers/isReadTypebotForbidden'
export const getResultLogs = authenticatedProcedure
.meta({
@@ -22,12 +22,24 @@ export const getResultLogs = authenticatedProcedure
)
.output(z.object({ logs: z.array(logSchema) }))
.query(async ({ input: { typebotId, resultId }, ctx: { user } }) => {
const typebot = await getTypebot({
accessLevel: 'read',
user,
typebotId,
const typebot = await prisma.typebot.findUnique({
where: {
id: typebotId,
},
select: {
id: true,
workspaceId: true,
groups: true,
collaborators: {
select: {
userId: true,
type: true,
},
},
},
})
if (!typebot) throw new Error('Typebot not found')
if (!typebot || (await isReadTypebotForbidden(typebot, user)))
throw new Error('Typebot not found')
const logs = await prisma.log.findMany({
where: {
resultId,

View File

@@ -1,9 +1,9 @@
import { getTypebot } from '@/features/typebot/helpers/getTypebot'
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { ResultWithAnswers, resultWithAnswersSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { isReadTypebotForbidden } from '@/features/typebot/helpers/isReadTypebotForbidden'
const maxLimit = 200
@@ -38,12 +38,23 @@ export const getResults = authenticatedProcedure
message: 'limit must be between 1 and 200',
})
const { cursor } = input
const typebot = await getTypebot({
accessLevel: 'read',
user,
typebotId: input.typebotId,
const typebot = await prisma.typebot.findUnique({
where: {
id: input.typebotId,
},
select: {
id: true,
workspaceId: true,
groups: true,
collaborators: {
select: {
userId: true,
type: true,
},
},
},
})
if (!typebot?.id)
if (!typebot || (await isReadTypebotForbidden(typebot, user)))
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const results = (await prisma.result.findMany({
take: limit + 1,

View File

@@ -67,10 +67,12 @@ export const ResultsTable = ({
const changeColumnOrder = (newColumnOrder: string[]) => {
if (typeof newColumnOrder === 'function') return
updateTypebot({
resultsTablePreferences: {
columnsOrder: newColumnOrder,
columnsVisibility,
columnsWidth,
updates: {
resultsTablePreferences: {
columnsOrder: newColumnOrder,
columnsVisibility,
columnsWidth,
},
},
})
}
@@ -80,10 +82,12 @@ export const ResultsTable = ({
) => {
if (typeof newColumnVisibility === 'function') return
updateTypebot({
resultsTablePreferences: {
columnsVisibility: newColumnVisibility,
columnsWidth,
columnsOrder,
updates: {
resultsTablePreferences: {
columnsVisibility: newColumnVisibility,
columnsWidth,
columnsOrder,
},
},
})
}
@@ -93,10 +97,12 @@ export const ResultsTable = ({
) => {
if (typeof newColumnSizing === 'object') return
updateTypebot({
resultsTablePreferences: {
columnsWidth: newColumnSizing(columnsWidth),
columnsVisibility,
columnsOrder,
updates: {
resultsTablePreferences: {
columnsWidth: newColumnSizing(columnsWidth),
columnsVisibility,
columnsOrder,
},
},
})
}

View File

@@ -22,13 +22,17 @@ export const SettingsSideMenu = () => {
const handleTypingEmulationChange = (typingEmulation: TypingEmulation) =>
typebot &&
updateTypebot({ settings: { ...typebot.settings, typingEmulation } })
updateTypebot({
updates: { settings: { ...typebot.settings, typingEmulation } },
})
const handleGeneralSettingsChange = (general: GeneralSettings) =>
typebot && updateTypebot({ settings: { ...typebot.settings, general } })
typebot &&
updateTypebot({ updates: { settings: { ...typebot.settings, general } } })
const handleMetadataChange = (metadata: Metadata) =>
typebot && updateTypebot({ settings: { ...typebot.settings, metadata } })
typebot &&
updateTypebot({ updates: { settings: { ...typebot.settings, metadata } } })
return (
<Stack

View File

@@ -12,7 +12,7 @@ test.describe.parallel('Settings page', () => {
id: typebotId,
})
await page.goto(`/typebots/${typebotId}/settings`)
await page.click('text="Remember user"')
await expect(page.getByPlaceholder('Type your answer...')).toHaveValue(

View File

@@ -15,8 +15,7 @@ import { TemplatesModal } from './TemplatesModal'
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
import { useUser } from '@/features/account/hooks/useUser'
import { useToast } from '@/hooks/useToast'
import { createTypebotQuery } from '@/features/dashboard/queries/createTypebotQuery'
import { importTypebotQuery } from '@/features/dashboard/queries/importTypebotQuery'
import { trpc } from '@/lib/trpc'
export const CreateNewTypebotButtons = () => {
const { workspace } = useWorkspace()
@@ -28,40 +27,16 @@ export const CreateNewTypebotButtons = () => {
const { showToast } = useToast()
const handleCreateSubmit = async (typebot?: Typebot) => {
if (!user || !workspace) return
setIsLoading(true)
const folderId = router.query.folderId?.toString() ?? null
const { error, data } = typebot
? await importTypebotQuery(
{
...typebot,
folderId,
workspaceId: workspace.id,
theme: {
...typebot.theme,
chat: {
...typebot.theme.chat,
hostAvatar: {
isEnabled: true,
url:
typebot.theme.chat.hostAvatar?.url ??
user.image ??
undefined,
},
},
},
},
workspace.plan
)
: await createTypebotQuery({
folderId,
workspaceId: workspace.id,
})
if (error) showToast({ description: error.message })
if (data)
const { mutate } = trpc.typebot.createTypebot.useMutation({
onMutate: () => {
setIsLoading(true)
},
onError: (error) => {
showToast({ description: error.message })
},
onSuccess: (data) => {
router.push({
pathname: `/typebots/${data.id}/edit`,
pathname: `/typebots/${data.typebot.id}/edit`,
query:
router.query.isFirstBot === 'true'
? {
@@ -69,7 +44,22 @@ export const CreateNewTypebotButtons = () => {
}
: {},
})
setIsLoading(false)
},
onSettled: () => {
setIsLoading(false)
},
})
const handleCreateSubmit = async (typebot?: Typebot) => {
if (!user || !workspace) return
const folderId = router.query.folderId?.toString() ?? null
mutate({
workspaceId: workspace.id,
typebot: {
...(typebot ? { ...typebot } : {}),
folderId,
},
})
}
return (

View File

@@ -1,8 +1,8 @@
import { parseInvalidTypebot } from '@/features/typebot/helpers/parseInvalidTypebot'
import { useToast } from '@/hooks/useToast'
import { Button, ButtonProps, chakra } from '@chakra-ui/react'
import { groupSchema, Typebot } from '@typebot.io/schemas'
import { Typebot, typebotSchema } from '@typebot.io/schemas'
import React, { ChangeEvent } from 'react'
import { z } from 'zod'
type Props = {
onNewTypebot: (typebot: Typebot) => void
@@ -19,8 +19,13 @@ export const ImportTypebotFromFileButton = ({
const file = e.target.files[0]
const fileContent = await readFile(file)
try {
const typebot = JSON.parse(fileContent)
z.array(groupSchema).parse(typebot.groups)
const typebot = parseInvalidTypebot(JSON.parse(fileContent))
typebotSchema
.omit({
createdAt: true,
updatedAt: true,
})
.parse(typebot)
onNewTypebot(typebot)
} catch (err) {
console.error(err)

View File

@@ -23,13 +23,15 @@ export const ThemeSideMenu = () => {
const { typebot, updateTypebot } = useTypebot()
const updateChatTheme = (chat: ChatTheme) =>
typebot && updateTypebot({ theme: { ...typebot.theme, chat } })
typebot && updateTypebot({ updates: { theme: { ...typebot.theme, chat } } })
const updateGeneralTheme = (general: GeneralTheme) =>
typebot && updateTypebot({ theme: { ...typebot.theme, general } })
typebot &&
updateTypebot({ updates: { theme: { ...typebot.theme, general } } })
const updateCustomCss = (customCss: string) =>
typebot && updateTypebot({ theme: { ...typebot.theme, customCss } })
typebot &&
updateTypebot({ updates: { theme: { ...typebot.theme, customCss } } })
const selectedTemplate = (
selectedTemplate: Partial<Pick<ThemeTemplate, 'id' | 'theme'>>
@@ -37,15 +39,19 @@ export const ThemeSideMenu = () => {
if (!typebot) return
const { theme, id } = selectedTemplate
updateTypebot({
selectedThemeTemplateId: id,
theme: theme ? { ...theme } : typebot.theme,
updates: {
selectedThemeTemplateId: id,
theme: theme ? { ...theme } : typebot.theme,
},
})
}
const updateBranding = (isBrandingEnabled: boolean) =>
typebot &&
updateTypebot({
settings: { ...typebot.settings, general: { isBrandingEnabled } },
updates: {
settings: { ...typebot.settings, general: { isBrandingEnabled } },
},
})
return (

View File

@@ -0,0 +1,129 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { Plan, WorkspaceRole } from '@typebot.io/prisma'
import {
defaultSettings,
defaultTheme,
typebotSchema,
} from '@typebot.io/schemas'
import { z } from 'zod'
import { getUserRoleInWorkspace } from '@/features/workspace/helpers/getUserRoleInWorkspace'
import {
isCustomDomainNotAvailable,
isPublicIdNotAvailable,
sanitizeGroups,
sanitizeSettings,
} from '../helpers/sanitizers'
import { createId } from '@paralleldrive/cuid2'
export const createTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots',
protect: true,
summary: 'Create a typebot',
tags: ['Typebot'],
},
})
.input(
z.object({
workspaceId: z.string(),
typebot: typebotSchema
.pick({
name: true,
icon: true,
selectedThemeTemplateId: true,
groups: true,
theme: true,
settings: true,
folderId: true,
variables: true,
edges: true,
resultsTablePreferences: true,
publicId: true,
customDomain: true,
})
.partial(),
})
)
.output(
z.object({
typebot: typebotSchema,
})
)
.mutation(async ({ input: { typebot, workspaceId }, ctx: { user } }) => {
const workspace = await prisma.workspace.findUnique({
where: { id: workspaceId },
select: { id: true, members: true, plan: true },
})
const userRole = getUserRoleInWorkspace(user.id, workspace?.members)
if (
userRole === undefined ||
userRole === WorkspaceRole.GUEST ||
!workspace
)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' })
if (
typebot.customDomain &&
(await isCustomDomainNotAvailable(typebot.customDomain))
)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Custom domain not available',
})
if (typebot.publicId && (await isPublicIdNotAvailable(typebot.publicId)))
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Public id not available',
})
const newTypebot = await prisma.typebot.create({
data: {
version: '5',
workspaceId,
name: typebot.name ?? 'My typebot',
icon: typebot.icon,
selectedThemeTemplateId: typebot.selectedThemeTemplateId,
groups: typebot.groups
? await sanitizeGroups(workspaceId)(typebot.groups)
: defaultGroups(),
theme: typebot.theme ? typebot.theme : defaultTheme,
settings: typebot.settings
? sanitizeSettings(typebot.settings, workspace.plan)
: defaultSettings({
isBrandingEnabled: workspace.plan === Plan.FREE,
}),
folderId: typebot.folderId,
variables: typebot.variables ?? [],
edges: typebot.edges ?? [],
resultsTablePreferences: typebot.resultsTablePreferences ?? undefined,
publicId: typebot.publicId ?? undefined,
customDomain: typebot.customDomain ?? undefined,
},
})
return { typebot: typebotSchema.parse(newTypebot) }
})
const defaultGroups = () => {
const groupId = createId()
return [
{
id: groupId,
title: 'Start',
graphCoordinates: { x: 0, y: 0 },
blocks: [
{
groupId,
id: createId(),
label: 'Start',
type: 'start',
},
],
},
]
}

View File

@@ -0,0 +1,73 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { Group } from '@typebot.io/schemas'
import { z } from 'zod'
import { isWriteTypebotForbidden } from '../helpers/isWriteTypebotForbidden'
import { archiveResults } from '@typebot.io/lib/api/helpers/archiveResults'
export const deleteTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'DELETE',
path: '/typebots/{typebotId}',
protect: true,
summary: 'Delete a typebot',
tags: ['Typebot'],
},
})
.input(
z.object({
typebotId: z.string(),
})
)
.output(
z.object({
message: z.literal('success'),
})
)
.mutation(async ({ input: { typebotId }, ctx: { user } }) => {
const existingTypebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
},
select: {
id: true,
workspaceId: true,
groups: true,
collaborators: {
select: {
userId: true,
type: true,
},
},
},
})
if (
!existingTypebot?.id ||
(await isWriteTypebotForbidden(existingTypebot, user))
)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const { success } = await archiveResults(prisma)({
typebot: {
groups: existingTypebot.groups as Group[],
},
resultsFilter: { typebotId },
})
if (!success)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to archive results',
})
await prisma.publicTypebot.deleteMany({
where: { typebotId },
})
await prisma.typebot.updateMany({
where: { id: typebotId },
data: { isArchived: true, publicId: null, customDomain: null },
})
return {
message: 'success',
}
})

View File

@@ -0,0 +1,64 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { publicTypebotSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { isReadTypebotForbidden } from '../helpers/isReadTypebotForbidden'
export const getPublishedTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/publishedTypebot',
protect: true,
summary: 'Get published typebot',
tags: ['Typebot'],
},
})
.input(
z.object({
typebotId: z.string(),
})
)
.output(
z.object({
publishedTypebot: publicTypebotSchema.nullable(),
})
)
.query(async ({ input: { typebotId }, ctx: { user } }) => {
const existingTypebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
},
include: {
collaborators: true,
publishedTypebot: true,
},
})
if (
!existingTypebot?.id ||
(await isReadTypebotForbidden(existingTypebot, user))
)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
if (!existingTypebot.publishedTypebot)
return {
publishedTypebot: null,
}
try {
const parsedTypebot = publicTypebotSchema.parse(
existingTypebot.publishedTypebot
)
return {
publishedTypebot: parsedTypebot,
}
} catch (err) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to parse published typebot',
cause: err,
})
}
})

View File

@@ -0,0 +1,75 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { Typebot, typebotSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { isReadTypebotForbidden } from '../helpers/isReadTypebotForbidden'
import { omit } from '@typebot.io/lib'
import { Typebot as TypebotFromDb } from '@typebot.io/prisma'
import { migrateTypebotFromV3ToV4 } from '@typebot.io/lib/migrations/migrateTypebotFromV3ToV4'
import { parseInvalidTypebot } from '../helpers/parseInvalidTypebot'
export const getTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}',
protect: true,
summary: 'Get a typebot',
tags: ['Typebot'],
},
})
.input(
z.object({
typebotId: z.string(),
})
)
.output(
z.object({
typebot: typebotSchema,
isReadOnly: z.boolean(),
})
)
.query(async ({ input: { typebotId }, ctx: { user } }) => {
const existingTypebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
},
include: {
collaborators: true,
},
})
if (
!existingTypebot?.id ||
(await isReadTypebotForbidden(existingTypebot, user))
)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
try {
const parsedTypebot = await parseTypebot(
omit(existingTypebot, 'collaborators')
)
return {
typebot: parsedTypebot,
isReadOnly:
existingTypebot.collaborators.find(
(collaborator) => collaborator.userId === user.id
)?.type === 'READ' ?? false,
}
} catch (err) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to parse typebot',
cause: err,
})
}
})
const parseTypebot = async (typebot: TypebotFromDb): Promise<Typebot> => {
const parsedTypebot = typebotSchema.parse(
typebot.version !== '5' ? parseInvalidTypebot(typebot as Typebot) : typebot
)
if (['4', '5'].includes(parsedTypebot.version ?? '')) return parsedTypebot
return migrateTypebotFromV3ToV4(prisma)(parsedTypebot)
}

View File

@@ -0,0 +1,80 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { typebotSchema } from '@typebot.io/schemas'
import { z } from 'zod'
import { isWriteTypebotForbidden } from '../helpers/isWriteTypebotForbidden'
export const publishTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/publish',
protect: true,
summary: 'Publish a typebot',
tags: ['Typebot'],
},
})
.input(
z.object({
typebotId: z.string(),
})
)
.output(
z.object({
message: z.literal('success'),
})
)
.mutation(async ({ input: { typebotId }, ctx: { user } }) => {
const existingTypebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
},
include: {
collaborators: true,
publishedTypebot: true,
},
})
if (
!existingTypebot?.id ||
(await isWriteTypebotForbidden(existingTypebot, user))
)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
if (existingTypebot.publishedTypebot) {
await prisma.publicTypebot.updateMany({
where: {
id: existingTypebot.publishedTypebot.id,
},
data: {
version: existingTypebot.version,
edges: typebotSchema.shape.edges.parse(existingTypebot.edges),
groups: typebotSchema.shape.groups.parse(existingTypebot.groups),
settings: typebotSchema.shape.settings.parse(
existingTypebot.settings
),
variables: typebotSchema.shape.variables.parse(
existingTypebot.variables
),
theme: typebotSchema.shape.theme.parse(existingTypebot.theme),
},
})
return { message: 'success' }
}
await prisma.publicTypebot.createMany({
data: {
version: existingTypebot.version,
typebotId: existingTypebot.id,
edges: typebotSchema.shape.edges.parse(existingTypebot.edges),
groups: typebotSchema.shape.groups.parse(existingTypebot.groups),
settings: typebotSchema.shape.settings.parse(existingTypebot.settings),
variables: typebotSchema.shape.variables.parse(
existingTypebot.variables
),
theme: typebotSchema.shape.theme.parse(existingTypebot.theme),
},
})
return { message: 'success' }
})

View File

@@ -1,6 +1,20 @@
import { router } from '@/helpers/server/trpc'
import { listTypebots } from './listTypebots'
import { createTypebot } from './createTypebot'
import { updateTypebot } from './updateTypebot'
import { getTypebot } from './getTypebot'
import { getPublishedTypebot } from './getPublishedTypebot'
import { publishTypebot } from './publishTypebot'
import { unpublishTypebot } from './unpublishTypebot'
import { deleteTypebot } from './deleteTypebot'
export const typebotRouter = router({
createTypebot,
updateTypebot,
getTypebot,
getPublishedTypebot,
publishTypebot,
unpublishTypebot,
listTypebots,
deleteTypebot,
})

View File

@@ -0,0 +1,56 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
import { isWriteTypebotForbidden } from '../helpers/isWriteTypebotForbidden'
export const unpublishTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/unpublish',
protect: true,
summary: 'Unpublish a typebot',
tags: ['Typebot'],
},
})
.input(
z.object({
typebotId: z.string(),
})
)
.output(
z.object({
message: z.literal('success'),
})
)
.mutation(async ({ input: { typebotId }, ctx: { user } }) => {
const existingTypebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
},
include: {
collaborators: true,
publishedTypebot: true,
},
})
if (!existingTypebot?.publishedTypebot)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Published typebot not found',
})
if (
!existingTypebot.id ||
(await isWriteTypebotForbidden(existingTypebot, user))
)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
await prisma.publicTypebot.deleteMany({
where: {
id: existingTypebot.publishedTypebot.id,
},
})
return { message: 'success' }
})

View File

@@ -0,0 +1,163 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import {
defaultSettings,
defaultTheme,
typebotSchema,
} from '@typebot.io/schemas'
import { z } from 'zod'
import {
isCustomDomainNotAvailable,
isPublicIdNotAvailable,
sanitizeGroups,
sanitizeSettings,
} from '../helpers/sanitizers'
import { isWriteTypebotForbidden } from '../helpers/isWriteTypebotForbidden'
import { isCloudProdInstance } from '@/helpers/isCloudProdInstance'
import { Plan } from '@typebot.io/prisma'
export const updateTypebot = authenticatedProcedure
.meta({
openapi: {
method: 'PATCH',
path: '/typebots/{typebotId}',
protect: true,
summary: 'Update a typebot',
tags: ['Typebot'],
},
})
.input(
z.object({
typebotId: z.string(),
typebot: typebotSchema
.pick({
name: true,
icon: true,
selectedThemeTemplateId: true,
groups: true,
theme: true,
settings: true,
folderId: true,
variables: true,
edges: true,
isClosed: true,
resultsTablePreferences: true,
publicId: true,
customDomain: true,
})
.partial(),
updatedAt: z
.date()
.optional()
.describe(
'Used for checking if there is a newer version of the typebot in the database'
),
})
)
.output(
z.object({
typebot: typebotSchema,
})
)
.mutation(
async ({ input: { typebotId, typebot, updatedAt }, ctx: { user } }) => {
const existingTypebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
},
select: {
id: true,
customDomain: true,
publicId: true,
workspaceId: true,
collaborators: {
select: {
userId: true,
type: true,
},
},
workspace: {
select: {
plan: true,
},
},
updatedAt: true,
},
})
if (
!existingTypebot?.id ||
(await isWriteTypebotForbidden(existingTypebot, user))
)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
if (
updatedAt &&
updatedAt.getTime() > new Date(existingTypebot?.updatedAt).getTime()
)
throw new TRPCError({
code: 'CONFLICT',
message: 'Found newer version of the typebot in database',
})
if (
typebot.customDomain &&
existingTypebot.customDomain !== typebot.customDomain &&
(await isCustomDomainNotAvailable(typebot.customDomain))
)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Custom domain not available',
})
if (typebot.publicId) {
if (isCloudProdInstance && typebot.publicId.length < 4)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Public id should be at least 4 characters long',
})
if (
existingTypebot.publicId !== typebot.publicId &&
(await isPublicIdNotAvailable(typebot.publicId))
)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Public id not available',
})
}
const newTypebot = await prisma.typebot.update({
where: {
id: existingTypebot.id,
},
data: {
version: '5',
name: typebot.name,
icon: typebot.icon,
selectedThemeTemplateId: typebot.selectedThemeTemplateId,
groups: typebot.groups
? await sanitizeGroups(existingTypebot.workspaceId)(typebot.groups)
: [],
theme: typebot.theme ? typebot.theme : defaultTheme,
settings: typebot.settings
? sanitizeSettings(typebot.settings, existingTypebot.workspace.plan)
: defaultSettings({
isBrandingEnabled: existingTypebot.workspace.plan !== Plan.FREE,
}),
folderId: typebot.folderId,
variables: typebot.variables,
edges: typebot.edges,
resultsTablePreferences: typebot.resultsTablePreferences ?? undefined,
publicId: typebot.publicId ?? undefined,
customDomain: typebot.customDomain ?? undefined,
isClosed: typebot.isClosed ?? false,
},
})
return { typebot: typebotSchema.parse(newTypebot) }
}
)

View File

@@ -1,36 +0,0 @@
import prisma from '@/lib/prisma'
import { Prisma, User } from '@typebot.io/prisma'
import { isReadTypebotForbidden } from './isReadTypebotForbidden'
import { isWriteTypebotForbidden } from './isWriteTypebotForbidden'
type Props<T extends Prisma.TypebotSelect> = {
typebotId: string
user: Pick<User, 'id' | 'email'>
accessLevel: 'read' | 'write'
select?: T
}
export const getTypebot = async <T extends Prisma.TypebotSelect>({
typebotId,
user,
accessLevel,
select,
}: Props<T>) => {
const typebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
},
select: {
...select,
id: true,
workspaceId: true,
collaborators: { select: { userId: true, type: true } },
},
})
if (!typebot) return null
if (accessLevel === 'read' && (await isReadTypebotForbidden(typebot, user)))
return null
if (accessLevel === 'write' && (await isWriteTypebotForbidden(typebot, user)))
return null
return typebot
}

View File

@@ -0,0 +1,10 @@
import { Edge, Typebot, edgeSchema } from '@typebot.io/schemas'
export const parseInvalidTypebot = (typebot: Typebot): Typebot => ({
...typebot,
version: typebot.version as null | '3' | '4' | '5',
edges: parseInvalidEdges(typebot.edges),
})
const parseInvalidEdges = (edges: Edge[]) =>
edges?.filter((edge) => edgeSchema.safeParse(edge).success)

View File

@@ -1,9 +0,0 @@
import { omit } from '@typebot.io/lib'
export const removeTypebotOldProperties = (data: unknown) => {
if (!data || typeof data !== 'object') return data
if ('publishedTypebotId' in data) {
return omit(data, 'publishedTypebotId')
}
return data
}

View File

@@ -1,12 +0,0 @@
import { Typebot } from '@typebot.io/schemas'
export const roundGroupsCoordinate = (typebot: Typebot): Typebot => {
const groups = typebot.groups.map((group) => {
const { x, y } = group.graphCoordinates
return {
...group,
graphCoordinates: { x: Number(x.toFixed(2)), y: Number(y.toFixed(2)) },
}
})
return { ...typebot, groups }
}

View File

@@ -0,0 +1,152 @@
import prisma from '@/lib/prisma'
import { Plan } from '@typebot.io/prisma'
import {
Block,
InputBlockType,
IntegrationBlockType,
Typebot,
Webhook,
WebhookBlock,
defaultWebhookAttributes,
} from '@typebot.io/schemas'
import { HttpMethod } from '@typebot.io/schemas/features/blocks/integrations/webhook/enums'
export const sanitizeSettings = (
settings: Typebot['settings'],
workspacePlan: Plan
): Typebot['settings'] => ({
...settings,
general: {
...settings.general,
isBrandingEnabled:
workspacePlan === Plan.FREE ? false : settings.general.isBrandingEnabled,
},
})
export const sanitizeGroups =
(workspaceId: string) =>
async (groups: Typebot['groups']): Promise<Typebot['groups']> =>
Promise.all(
groups.map(async (group) => ({
...group,
blocks: await Promise.all(group.blocks.map(sanitizeBlock(workspaceId))),
}))
)
const sanitizeBlock =
(workspaceId: string) =>
async (block: Block): Promise<Block> => {
switch (block.type) {
case InputBlockType.PAYMENT:
return {
...block,
options: {
...block.options,
credentialsId: await sanitizeCredentialsId(workspaceId)(
block.options.credentialsId
),
},
}
case IntegrationBlockType.WEBHOOK:
return await sanitizeWebhookBlock(block)
case IntegrationBlockType.GOOGLE_SHEETS:
return {
...block,
options: {
...block.options,
credentialsId: await sanitizeCredentialsId(workspaceId)(
block.options.credentialsId
),
},
}
case IntegrationBlockType.OPEN_AI:
return {
...block,
options: {
...block.options,
credentialsId: await sanitizeCredentialsId(workspaceId)(
block.options.credentialsId
),
},
}
case IntegrationBlockType.EMAIL:
return {
...block,
options: {
...block.options,
credentialsId:
(await sanitizeCredentialsId(workspaceId)(
block.options.credentialsId
)) ?? 'default',
},
}
default:
return block
}
}
const sanitizeWebhookBlock = async (
block: WebhookBlock
): Promise<WebhookBlock> => {
if (!block.webhookId) return block
const webhook = await prisma.webhook.findFirst({
where: {
id: block.webhookId,
},
})
return {
...block,
webhookId: undefined,
options: {
...block.options,
webhook: webhook
? {
id: webhook.id,
url: webhook.url ?? undefined,
method: (webhook.method as Webhook['method']) ?? HttpMethod.POST,
headers: (webhook.headers as Webhook['headers']) ?? [],
queryParams: (webhook.queryParams as Webhook['headers']) ?? [],
body: webhook.body ?? undefined,
}
: {
...defaultWebhookAttributes,
id: block.webhookId ?? '',
},
},
}
}
const sanitizeCredentialsId =
(workspaceId: string) =>
async (credentialsId?: string): Promise<string | undefined> => {
if (!credentialsId) return
const credentials = await prisma.credentials.findFirst({
where: {
id: credentialsId,
workspaceId,
},
select: {
id: true,
},
})
return credentials?.id
}
export const isPublicIdNotAvailable = async (publicId: string) => {
const typebotWithSameIdCount = await prisma.typebot.count({
where: {
publicId,
},
})
return typebotWithSameIdCount > 0
}
export const isCustomDomainNotAvailable = async (customDomain: string) => {
const typebotWithSameDomainCount = await prisma.typebot.count({
where: {
customDomain,
},
})
return typebotWithSameDomainCount > 0
}

View File

@@ -1,2 +1,4 @@
export const isCloudProdInstance =
typeof window !== 'undefined' && window.location.hostname === 'app.typebot.io'
(typeof window !== 'undefined' &&
window.location.hostname === 'app.typebot.io') ||
process.env.NEXTAUTH_URL === 'https://app.typebot.io'

View File

@@ -10,6 +10,7 @@ import { typebotRouter } from '@/features/typebot/api/router'
import { workspaceRouter } from '@/features/workspace/api/router'
import { router } from '../../trpc'
import { analyticsRouter } from '@/features/analytics/api/router'
import { collaboratorsRouter } from '@/features/collaboration/api/router'
export const trpcRouter = router({
getAppVersionProcedure,
@@ -23,6 +24,7 @@ export const trpcRouter = router({
billing: billingRouter,
credentials: credentialsRouter,
theme: themeRouter,
collaborators: collaboratorsRouter,
})
export type AppRouter = typeof trpcRouter

View File

@@ -7,7 +7,8 @@ export const useAutoSave = <T>(
item,
debounceTimeout,
}: {
handler: () => Promise<void>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handler: () => Promise<any>
item?: T
debounceTimeout: number
},

View File

@@ -1,33 +0,0 @@
import { fetcher } from '@/helpers/fetcher'
import { PublicTypebot, Typebot, Webhook } from '@typebot.io/schemas'
import useSWR from 'swr'
import { env } from '@typebot.io/lib'
export const useTypebotQuery = ({
typebotId,
onError,
}: {
typebotId?: string
onError?: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
{
typebot: Typebot
webhooks: Webhook[]
publishedTypebot?: PublicTypebot
isReadOnly?: boolean
},
Error
>(typebotId ? `/api/typebots/${typebotId}` : null, fetcher, {
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
})
if (error && onError) onError(error)
return {
typebot: data?.typebot,
webhooks: data?.webhooks,
publishedTypebot: data?.publishedTypebot,
isReadOnly: data?.isReadOnly,
isLoading: !error && !data,
mutate,
}
}

View File

@@ -1,4 +1,4 @@
import { httpBatchLink, loggerLink } from '@trpc/client'
import { createTRPCProxyClient, httpBatchLink, loggerLink } from '@trpc/client'
import { createTRPCNext } from '@trpc/next'
import type { AppRouter } from '../helpers/server/routers/v1/trpcRouter'
import superjson from 'superjson'
@@ -26,6 +26,15 @@ export const trpc = createTRPCNext<AppRouter>({
ssr: false,
})
export const trpcVanilla = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
transformer: superjson,
})
export const defaultQueryOptions = {
refetchOnMount: env('E2E_TEST') === 'true',
}

View File

@@ -14,6 +14,7 @@ import {
import { omit } from '@typebot.io/lib'
import { sendTelemetryEvents } from '@typebot.io/lib/telemetry/sendTelemetryEvent'
// TODO: Delete
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req, res)
if (!user) return notAuthenticated(res)

View File

@@ -3,15 +3,14 @@ import prisma from '@/lib/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed, notAuthenticated } from '@typebot.io/lib/api'
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
import { Typebot } from '@typebot.io/schemas'
import { Group, Typebot } from '@typebot.io/schemas'
import { omit } from '@typebot.io/lib'
import { getTypebot } from '@/features/typebot/helpers/getTypebot'
import { isReadTypebotForbidden } from '@/features/typebot/helpers/isReadTypebotForbidden'
import { removeTypebotOldProperties } from '@/features/typebot/helpers/removeTypebotOldProperties'
import { roundGroupsCoordinate } from '@/features/typebot/helpers/roundGroupsCoordinate'
import { archiveResults } from '@typebot.io/lib/api/helpers/archiveResults'
import { migrateTypebotFromV3ToV4 } from '@typebot.io/lib/migrations/migrateTypebotFromV3ToV4'
import { isWriteTypebotForbidden } from '@/features/typebot/helpers/isWriteTypebotForbidden'
// TODO: delete
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req, res)
if (!user) return notAuthenticated(res)
@@ -46,17 +45,28 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
if (req.method === 'DELETE') {
const typebot = (await getTypebot({
accessLevel: 'write',
user,
typebotId,
select: {
groups: true,
const typebot = await prisma.typebot.findUnique({
where: {
id: typebotId,
},
})) as Pick<Typebot, 'groups'> | null
if (!typebot) return res.status(404).send({ typebot: null })
select: {
id: true,
workspaceId: true,
groups: true,
collaborators: {
select: {
userId: true,
type: true,
},
},
},
})
if (!typebot || (await isWriteTypebotForbidden(typebot, user)))
return res.status(404).send({ typebot: null })
const { success } = await archiveResults(prisma)({
typebot,
typebot: {
groups: typebot.groups as Group[],
},
resultsFilter: { typebotId },
})
if (!success) return res.status(500).send({ success: false, error: '' })
@@ -75,15 +85,25 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as Typebot
const typebot = await getTypebot({
accessLevel: 'write',
typebotId,
user,
const typebot = await prisma.typebot.findUnique({
where: {
id: typebotId,
},
select: {
id: true,
workspaceId: true,
groups: true,
updatedAt: true,
collaborators: {
select: {
userId: true,
type: true,
},
},
},
})
if (!typebot) return res.status(404).send({ message: 'Typebot not found' })
if (!typebot || (await isWriteTypebotForbidden(typebot, user)))
return res.status(404).send({ message: 'Typebot not found' })
if (
(typebot.updatedAt as Date).getTime() > new Date(data.updatedAt).getTime()
@@ -124,12 +144,24 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
if (req.method === 'PATCH') {
const typebot = await getTypebot({
accessLevel: 'write',
typebotId,
user,
const typebot = await prisma.typebot.findUnique({
where: {
id: typebotId,
},
select: {
id: true,
workspaceId: true,
groups: true,
collaborators: {
select: {
userId: true,
type: true,
},
},
},
})
if (!typebot) return res.status(404).send({ message: 'Typebot not found' })
if (!typebot || (await isWriteTypebotForbidden(typebot, user)))
return res.status(404).send({ message: 'Typebot not found' })
const data = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
const updatedTypebot = await prisma.typebot.update({
where: { id: typebotId },
@@ -142,10 +174,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const migrateTypebot = async (typebot: Typebot): Promise<Typebot> => {
if (typebot.version === '4') return typebot
const updatedTypebot = roundGroupsCoordinate(
removeTypebotOldProperties(typebot) as Typebot
)
return migrateTypebotFromV3ToV4(prisma)(updatedTypebot)
return migrateTypebotFromV3ToV4(prisma)(typebot)
}
export default handler

View File

@@ -1,32 +0,0 @@
import prisma from '@/lib/prisma'
import { defaultWebhookAttributes, Webhook } from '@typebot.io/schemas'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
import {
methodNotAllowed,
notAuthenticated,
notFound,
} from '@typebot.io/lib/api'
import { getTypebot } from '@/features/typebot/helpers/getTypebot'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req, res)
if (!user) return notAuthenticated(res)
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const data = req.body.data as Partial<Webhook>
const typebot = await getTypebot({
accessLevel: 'write',
user,
typebotId,
})
if (!typebot) return notFound(res)
const webhook = await prisma.webhook.create({
data: { ...defaultWebhookAttributes, ...data, typebotId },
})
return res.send({ webhook })
}
methodNotAllowed(res)
}
export default handler

View File

@@ -1,55 +0,0 @@
import prisma from '@/lib/prisma'
import { Webhook } from '@typebot.io/schemas'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser'
import {
badRequest,
forbidden,
methodNotAllowed,
notAuthenticated,
notFound,
} from '@typebot.io/lib/api'
import { getTypebot } from '@/features/typebot/helpers/getTypebot'
import { omit } from '@typebot.io/lib'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req, res)
const typebotId = req.query.typebotId as string
const webhookId = req.query.webhookId as string
if (!user) return notAuthenticated(res)
if (req.method === 'GET') {
const typebot = getTypebot({
accessLevel: 'read',
typebotId,
user,
})
if (!typebot) return notFound(res)
const webhook = await prisma.webhook.findFirst({
where: {
id: webhookId,
typebotId,
},
})
return res.send({ webhook })
}
if (req.method === 'PATCH') {
const data = req.body.data as Partial<Webhook>
if (!('typebotId' in data)) return badRequest(res)
const typebot = await getTypebot({
accessLevel: 'write',
typebotId,
user,
})
if (!typebot) return forbidden(res)
const webhook = await prisma.webhook.update({
where: {
id: webhookId,
},
data: omit(data, 'id', 'typebotId'),
})
return res.send({ webhook })
}
methodNotAllowed(res)
}
export default handler

File diff suppressed because it is too large Load Diff

View File

@@ -584,8 +584,7 @@
}
},
"required": [
"labels",
"retryMessageContent"
"labels"
],
"additionalProperties": false
}
@@ -4342,8 +4341,7 @@
}
},
"required": [
"labels",
"retryMessageContent"
"labels"
],
"additionalProperties": false
}

View File

@@ -13,6 +13,7 @@ import {
SetVariableBlock,
WebhookBlock,
defaultPaymentInputOptions,
invalidEmailDefaultRetryMessage,
} from '@typebot.io/schemas'
import { isInputBlock, byId } from '@typebot.io/lib'
import { executeGroup } from './executeGroup'
@@ -207,6 +208,8 @@ const parseRetryMessage = (
const parseDefaultRetryMessage = (block: InputBlock): string => {
switch (block.type) {
case InputBlockType.EMAIL:
return invalidEmailDefaultRetryMessage
case InputBlockType.PAYMENT:
return defaultPaymentInputOptions.retryMessageContent as string
default:

View File

@@ -12,15 +12,17 @@ import {
} from '@typebot.io/lib/playwright/databaseActions'
import { parseDefaultGroupWithBlock } from '@typebot.io/lib/playwright/databaseHelpers'
const settings = defaultSettings({ isBrandingEnabled: true })
test('Result should be overwritten on page refresh', async ({ page }) => {
const typebotId = createId()
await createTypebots([
{
id: typebotId,
settings: {
...defaultSettings,
...settings,
general: {
...defaultSettings.general,
...settings.general,
rememberUser: {
isEnabled: true,
storage: 'session',
@@ -96,8 +98,8 @@ test('Hide query params', async ({ page }) => {
await updateTypebot({
id: typebotId,
settings: {
...defaultSettings,
general: { ...defaultSettings.general, isHideQueryParamsEnabled: false },
...settings,
general: { ...settings.general, isHideQueryParamsEnabled: false },
},
})
await page.goto(`/${typebotId}-public?Name=John`)
@@ -136,7 +138,7 @@ test('Should correctly parse metadata', async ({ page }) => {
{
id: typebotId,
settings: {
...defaultSettings,
...settings,
metadata: customMetadata,
},
...parseDefaultGroupWithBlock({

View File

@@ -21,7 +21,7 @@ export const parseTestTypebot = (
folderId: null,
name: 'My typebot',
theme: defaultTheme,
settings: defaultSettings,
settings: defaultSettings({ isBrandingEnabled: true }),
publicId: null,
updatedAt: new Date(),
createdAt: new Date(),

View File

@@ -8,7 +8,7 @@ export const emailInputOptionsSchema = optionBaseSchema
.merge(textInputOptionsBaseSchema)
.merge(
z.object({
retryMessageContent: z.string(),
retryMessageContent: z.string().optional(),
})
)
@@ -19,13 +19,14 @@ export const emailInputSchema = blockBaseSchema.merge(
})
)
export const invalidEmailDefaultRetryMessage =
"This email doesn't seem to be valid. Can you type it again?"
export const defaultEmailInputOptions: EmailInputOptions = {
labels: {
button: defaultButtonLabel,
placeholder: 'Type your email...',
},
retryMessageContent:
"This email doesn't seem to be valid. Can you type it again?",
}
export type EmailInputBlock = z.infer<typeof emailInputSchema>

View File

@@ -0,0 +1,10 @@
import { CollaborationType, CollaboratorsOnTypebots } from '@typebot.io/prisma'
import { z } from 'zod'
export const collaboratorSchema = z.object({
type: z.nativeEnum(CollaborationType),
userId: z.string(),
typebotId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
}) satisfies z.ZodType<CollaboratorsOnTypebots>

View File

@@ -11,7 +11,7 @@ import { z } from 'zod'
export const publicTypebotSchema = z.object({
id: z.string(),
version: z.enum(['3', '4']).nullable(),
version: z.enum(['3', '4', '5']).nullable(),
createdAt: z.date(),
updatedAt: z.date(),
typebotId: z.string(),

View File

@@ -37,9 +37,13 @@ export const settingsSchema = z.object({
metadata: metadataSchema,
})
export const defaultSettings: Settings = {
export const defaultSettings = ({
isBrandingEnabled,
}: {
isBrandingEnabled: boolean
}): Settings => ({
general: {
isBrandingEnabled: true,
isBrandingEnabled,
rememberUser: {
isEnabled: false,
},
@@ -51,7 +55,7 @@ export const defaultSettings: Settings = {
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.',
},
}
})
export type Settings = z.infer<typeof settingsSchema>
export type GeneralSettings = z.infer<typeof generalSettings>

View File

@@ -38,8 +38,11 @@ const resultsTablePreferencesSchema = z.object({
columnsWidth: z.record(z.string(), z.number()),
})
const isPathNameCompatible = (str: string) =>
/^([a-z0-9]+-[a-z0-9]*)*$/.test(str) || /^[a-z0-9]*$/.test(str)
export const typebotSchema = z.object({
version: z.enum(['3', '4']).nullable(),
version: z.enum(['3', '4', '5']).nullable(),
id: z.string(),
name: z.string(),
groups: z.array(groupSchema),
@@ -52,8 +55,8 @@ export const typebotSchema = z.object({
updatedAt: z.date(),
icon: z.string().nullable(),
folderId: z.string().nullable(),
publicId: z.string().nullable(),
customDomain: z.string().nullable(),
publicId: z.string().refine(isPathNameCompatible).nullable(),
customDomain: z.string().refine(isPathNameCompatible).nullable(),
workspaceId: z.string(),
resultsTablePreferences: resultsTablePreferencesSchema.nullable(),
isArchived: z.boolean(),