2
0

♻️ (viewer) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 10:28:03 +01:00
committed by Baptiste Arnaud
parent 643571fe7d
commit a9d04798bc
80 changed files with 523 additions and 491 deletions

View File

@ -0,0 +1,6 @@
body {
margin: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica,
Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
}

View File

@ -0,0 +1,33 @@
import React from 'react'
import { getViewerUrl, isEmpty } from 'utils'
export const ErrorPage = ({ error }: { error: Error }) => {
return (
<div
style={{
height: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
}}
>
{isEmpty(getViewerUrl()) ? (
<>
<h1 style={{ fontWeight: 'bold', fontSize: '30px' }}>
NEXT_PUBLIC_VIEWER_URL is missing
</h1>
<h2>
Make sure to configure the viewer properly (
<a href="https://docs.typebot.io/self-hosting/configuration#viewer">
https://docs.typebot.io/self-hosting/configuration#viewer
</a>
)
</h2>
</>
) : (
<p style={{ fontSize: '24px' }}>{error.message}</p>
)}
</div>
)
}

View File

@ -0,0 +1,14 @@
export const NotFoundPage = () => (
<div
style={{
height: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
}}
>
<h1 style={{ fontWeight: 'bold', fontSize: '30px' }}>404</h1>
<h2>The bot you&apos;re looking for doesn&apos;t exist</h2>
</div>
)

View File

@ -0,0 +1,65 @@
import { Metadata } from 'models'
import Head from 'next/head'
import React from 'react'
type SEOProps = {
url: string
typebotName: string
metadata: Metadata
}
export const SEO = ({
url,
typebotName,
metadata: { title, description, favIconUrl, imageUrl },
}: SEOProps) => (
<Head>
<title>{title ?? typebotName}</title>
<meta name="robots" content="noindex" />
<link
rel="icon"
type="image/png"
href={favIconUrl ?? 'https://bot.typebot.io/favicon.png'}
/>
<meta name="title" content={title ?? typebotName} />
<meta
name="description"
content={
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.'
}
/>
<meta property="og:type" content="website" />
<meta property="og:url" content={url ?? 'https://bot.typebot.io'} />
<meta property="og:title" content={title ?? typebotName} />
<meta property="og:site_name" content={title ?? typebotName} />
<meta
property="og:description"
content={
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.'
}
/>
<meta
property="og:image"
itemProp="image"
content={imageUrl ?? 'https://bot.typebot.io/site-preview.png'}
/>
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={url ?? 'https://bot.typebot.io'} />
<meta property="twitter:title" content={title ?? typebotName} />
<meta
property="twitter:description"
content={
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.'
}
/>
<meta
property="twitter:image"
content={imageUrl ?? 'https://bot.typebot.io/site-preview.png'}
/>
</Head>
)

View File

@ -0,0 +1,159 @@
import { TypebotViewer } from 'bot-engine'
import { Answer, PublicTypebot, Typebot, VariableWithValue } from 'models'
import { useRouter } from 'next/router'
import React, { useEffect, useState } from 'react'
import { isDefined, isNotDefined } from 'utils'
import { SEO } from './Seo'
import { ErrorPage } from './ErrorPage'
import { createResultQuery, updateResultQuery } from '@/features/results'
import { upsertAnswerQuery } from '@/features/answers'
export type TypebotPageProps = {
publishedTypebot: Omit<PublicTypebot, 'createdAt' | 'updatedAt'> & {
typebot: Pick<Typebot, 'name' | 'isClosed' | 'isArchived'>
}
url: string
isIE: boolean
customHeadCode: string | null
}
const sessionStorageKey = 'resultId'
export const TypebotPage = ({
publishedTypebot,
isIE,
url,
customHeadCode,
}: TypebotPageProps) => {
const { asPath, push } = useRouter()
const [showTypebot, setShowTypebot] = useState(false)
const [predefinedVariables, setPredefinedVariables] = useState<{
[key: string]: string
}>()
const [error, setError] = useState<Error | undefined>(
isIE ? new Error('Internet explorer is not supported') : undefined
)
const [resultId, setResultId] = useState<string | undefined>()
const [variableUpdateQueue, setVariableUpdateQueue] = useState<
VariableWithValue[][]
>([])
const [chatStarted, setChatStarted] = useState(false)
useEffect(() => {
setShowTypebot(true)
const urlParams = new URLSearchParams(location.search)
clearQueryParams()
const predefinedVariables: { [key: string]: string } = {}
urlParams.forEach((value, key) => {
predefinedVariables[key] = value
})
setPredefinedVariables(predefinedVariables)
initializeResult().then()
if (isDefined(customHeadCode))
document.head.innerHTML = document.head.innerHTML + customHeadCode
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const clearQueryParams = () => {
const hasQueryParams = asPath.includes('?')
if (
hasQueryParams &&
publishedTypebot.settings.general.isHideQueryParamsEnabled !== false
)
push(asPath.split('?')[0], undefined, { shallow: true })
}
const initializeResult = async () => {
const resultIdFromSession = getExistingResultFromSession()
if (resultIdFromSession) setResultId(resultIdFromSession)
else {
const { error, data } = await createResultQuery(
publishedTypebot.typebotId
)
if (error) return setError(error)
if (data?.hasReachedLimit)
return setError(new Error('This bot is now closed.'))
if (data?.result) {
setResultId(data.result.id)
if (
publishedTypebot.settings.general.isNewResultOnRefreshEnabled !== true
)
setResultInSession(data.result.id)
}
}
}
useEffect(() => {
if (!resultId || variableUpdateQueue.length === 0) return
Promise.all(variableUpdateQueue.map(sendNewVariables(resultId))).then()
setVariableUpdateQueue([])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resultId])
const handleNewVariables = async (variables: VariableWithValue[]) => {
if (variables.length === 0) return
if (!resultId)
return setVariableUpdateQueue([...variableUpdateQueue, variables])
await sendNewVariables(resultId)(variables)
}
const sendNewVariables =
(resultId: string) => async (variables: VariableWithValue[]) => {
const { error } = await updateResultQuery(resultId, { variables })
if (error) setError(error)
}
const handleNewAnswer = async (
answer: Answer & { uploadedFiles: boolean }
) => {
if (!resultId) return setError(new Error('Error: result was not created'))
const { error } = await upsertAnswerQuery({ ...answer, resultId })
if (error) setError(error)
if (chatStarted) return
updateResultQuery(resultId, {
hasStarted: true,
}).then(({ error }) => (error ? setError(error) : setChatStarted(true)))
}
const handleCompleted = async () => {
if (!resultId) return setError(new Error('Error: result was not created'))
const { error } = await updateResultQuery(resultId, { isCompleted: true })
if (error) setError(error)
}
if (error) {
return <ErrorPage error={error} />
}
return (
<div style={{ height: '100vh' }}>
<SEO
url={url}
typebotName={publishedTypebot.typebot.name}
metadata={publishedTypebot.settings.metadata}
/>
{showTypebot && (
<TypebotViewer
typebot={publishedTypebot}
resultId={resultId}
predefinedVariables={predefinedVariables}
onNewAnswer={handleNewAnswer}
onCompleted={handleCompleted}
onVariablesUpdated={handleNewVariables}
isLoading={isNotDefined(resultId)}
/>
)}
</div>
)
}
const getExistingResultFromSession = () => {
try {
return sessionStorage.getItem(sessionStorageKey)
} catch {}
}
const setResultInSession = (resultId: string) => {
try {
return sessionStorage.setItem(sessionStorageKey, resultId)
} catch {}
}

View File

@ -0,0 +1 @@
export { upsertAnswerQuery } from './queries/upsertAnswerQuery'

View File

@ -0,0 +1,11 @@
import { Answer } from 'models'
import { sendRequest } from 'utils'
export const upsertAnswerQuery = async (
answer: Answer & { resultId: string } & { uploadedFiles?: boolean }
) =>
sendRequest<Answer>({
url: `/api/typebots/t/results/r/answers`,
method: 'PUT',
body: answer,
})

View File

@ -0,0 +1,19 @@
import { User } from 'db'
import { NextApiRequest } from 'next'
import prisma from '@/lib/prisma'
export const authenticateUser = async (
req: NextApiRequest
): Promise<User | undefined> => authenticateByToken(extractBearerToken(req))
const authenticateByToken = async (
apiToken?: string
): Promise<User | undefined> => {
if (!apiToken) return
return (await prisma.user.findFirst({
where: { apiTokens: { some: { token: apiToken } } },
})) as User
}
const extractBearerToken = (req: NextApiRequest) =>
req.headers['authorization']?.slice(7)

View File

@ -0,0 +1 @@
export { authenticateUser } from './authenticateUser'

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export * from './saveErrorLog'
export * from './saveSuccessLog'

View File

@ -0,0 +1,7 @@
import { saveLog } from './utils'
export const saveErrorLog = (
resultId: string | undefined,
message: string,
details?: unknown
) => saveLog('error', resultId, message, details)

View File

@ -0,0 +1,7 @@
import { saveLog } from './utils'
export const saveSuccessLog = (
resultId: string | undefined,
message: string,
details?: unknown
) => saveLog('success', resultId, message, details)

View File

@ -0,0 +1,26 @@
import prisma from '@/lib/prisma'
export const saveLog = (
status: 'error' | 'success',
resultId: string | undefined,
message: string,
details?: unknown
) => {
if (!resultId || resultId === 'undefined') return
return prisma.log.create({
data: {
resultId,
status,
description: message,
details: formatDetails(details) as string,
},
})
}
const formatDetails = (details: unknown) => {
try {
return JSON.stringify(details, null, 2).substring(0, 1000)
} catch {
return details
}
}

View File

@ -0,0 +1 @@
export { createResultQuery, updateResultQuery } from './queries'

View File

@ -0,0 +1,9 @@
import { Result } from 'models'
import { sendRequest } from 'utils'
export const createResultQuery = async (typebotId: string) => {
return sendRequest<{ result: Result; hasReachedLimit: boolean }>({
url: `/api/typebots/${typebotId}/results`,
method: 'POST',
})
}

View File

@ -0,0 +1,2 @@
export * from './createResultQuery'
export * from './updateResultQuery'

View File

@ -0,0 +1,12 @@
import { Result } from 'models'
import { sendRequest } from 'utils'
export const updateResultQuery = async (
resultId: string,
result: Partial<Result>
) =>
sendRequest<Result>({
url: `/api/typebots/t/results/${resultId}`,
method: 'PATCH',
body: result,
})

View File

@ -0,0 +1,51 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import {
importTypebotInDatabase,
injectFakeResults,
} from 'utils/playwright/databaseActions'
import { apiToken } from 'utils/playwright/databaseSetup'
import { typebotViewer } from 'utils/playwright/testHelpers'
test('Big groups should work as expected', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/hugeGroup.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await page.goto(`/${typebotId}-public`)
await typebotViewer(page).locator('input').fill('Baptiste')
await typebotViewer(page).locator('input').press('Enter')
await typebotViewer(page).locator('input').fill('26')
await typebotViewer(page).locator('input').press('Enter')
await typebotViewer(page).locator('button >> text=Yes').click()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text="Baptiste"')).toBeVisible()
await expect(page.locator('text="26"')).toBeVisible()
await expect(page.locator('text="Yes"')).toBeVisible()
await page.hover('tbody > tr')
await page.click('button >> text="Open"')
await expect(page.locator('text="Baptiste" >> nth=1')).toBeVisible()
await expect(page.locator('text="26" >> nth=1')).toBeVisible()
await expect(page.locator('text="Yes" >> nth=1')).toBeVisible()
})
test('can list results with API', async ({ request }) => {
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/api.json'), {
id: typebotId,
})
await injectFakeResults({ typebotId, count: 20 })
expect(
(await request.get(`/api/typebots/${typebotId}/results`)).status()
).toBe(401)
const response = await request.get(
`/api/typebots/${typebotId}/results?limit=10`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const { results } = await response.json()
expect(results).toHaveLength(10)
})

View File

@ -0,0 +1,56 @@
import test, { expect } from '@playwright/test'
import { createSmtpCredentials } from '../../test/utils/databaseActions'
import cuid from 'cuid'
import { SmtpCredentialsData } from 'models'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
import { getTestAsset } from '@/test/utils/playwright'
const mockSmtpCredentials: SmtpCredentialsData = {
from: {
email: 'marley.cummings@ethereal.email',
name: 'Marley Cummings',
},
host: 'smtp.ethereal.email',
port: 587,
username: 'marley.cummings@ethereal.email',
password: 'E5W1jHbAmv5cXXcut2',
}
test.beforeAll(async () => {
try {
const credentialsId = 'send-email-credentials'
await createSmtpCredentials(credentialsId, mockSmtpCredentials)
} catch (err) {
console.error(err)
}
})
test('should send an email', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(getTestAsset('typebots/sendEmail.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await page.goto(`/${typebotId}-public`)
const [response] = await Promise.all([
page.waitForResponse((resp) =>
resp.request().url().includes(`integrations/email`)
),
typebotViewer(page).locator('text=Send email').click(),
])
const { previewUrl } = await response.json()
await page.goto(previewUrl)
await expect(page.locator('text="Hey!"')).toBeVisible()
await expect(
page.locator(`text="${mockSmtpCredentials.from.name}"`)
).toBeVisible()
await expect(page.locator('text="<test1@gmail.com>" >> nth=0')).toBeVisible()
await expect(page.locator('text="<test2@gmail.com>" >> nth=0')).toBeVisible()
await expect(
page.locator('text="<baptiste.arnaud95@gmail.com>" >> nth=0')
).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await page.click('text="See logs"')
await expect(page.locator('text="Email successfully sent"')).toBeVisible()
})

View File

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

View File

@ -0,0 +1,41 @@
import prisma from '@/lib/prisma'
import { canReadTypebots } from '@/utils/api/dbRules'
import { User } from 'db'
import {
LogicBlockType,
PublicTypebot,
Typebot,
TypebotLinkBlock,
} from 'models'
import { isDefined } from 'utils'
export const getLinkedTypebots = async (
typebot: Typebot | PublicTypebot,
user?: User
): Promise<(Typebot | PublicTypebot)[]> => {
const linkedTypebotIds = (
typebot.groups
.flatMap((g) => g.blocks)
.filter(
(s) =>
s.type === LogicBlockType.TYPEBOT_LINK &&
isDefined(s.options.typebotId)
) as TypebotLinkBlock[]
).map((s) => s.options.typebotId as string)
if (linkedTypebotIds.length === 0) return []
const typebots = (await ('typebotId' in typebot
? prisma.publicTypebot.findMany({
where: { id: { in: linkedTypebotIds } },
})
: prisma.typebot.findMany({
where: user
? {
AND: [
{ id: { in: linkedTypebotIds } },
canReadTypebots(linkedTypebotIds, user as User),
],
}
: { id: { in: linkedTypebotIds } },
}))) as unknown as (Typebot | PublicTypebot)[]
return typebots
}

View File

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

View File

@ -0,0 +1,38 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
const typebotId = 'cl0ibhi7s0018n21aarlmg0cm'
const linkedTypebotId = 'cl0ibhv8d0130n21aw8doxhj5'
test.beforeAll(async () => {
try {
await importTypebotInDatabase(
getTestAsset('typebots/linkTypebots/1.json'),
{ id: typebotId, publicId: `${typebotId}-public` }
)
await importTypebotInDatabase(
getTestAsset('typebots/linkTypebots/2.json'),
{ id: linkedTypebotId, publicId: `${linkedTypebotId}-public` }
)
} catch (err) {
console.error(err)
}
})
test('should work as expected', async ({ page }) => {
await page.goto(`/${typebotId}-public`)
await typebotViewer(page).locator('input').fill('Hello there!')
await Promise.all([
page.waitForResponse(
(resp) =>
resp.request().url().includes(`/api/typebots/t/results`) &&
resp.status() === 200 &&
resp.request().method() === 'PUT'
),
typebotViewer(page).locator('input').press('Enter'),
])
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text=Hello there!')).toBeVisible()
})

View File

@ -0,0 +1,19 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
// TODO: uncomment on 1st of November
// test('should not start if chat limit is reached', async ({ page }) => {
// const typebotId = cuid()
// await importTypebotInDatabase(
// path.join(__dirname, '../fixtures/typebots/fileUpload.json'),
// {
// id: typebotId,
// publicId: `${typebotId}-public`,
// workspaceId: limitTestWorkspaceId,
// }
// )
// await createResults({ typebotId, count: 320 })
// await page.goto(`/${typebotId}-public`)
// await expect(page.locator('text="This bot is now closed."')).toBeVisible()
// })

View File

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

View File

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

View File

@ -0,0 +1,192 @@
import {
InputBlock,
InputBlockType,
LogicBlockType,
PublicTypebot,
ResultHeaderCell,
Block,
Typebot,
TypebotLinkBlock,
} from 'models'
import { isInputBlock, byId, parseResultHeader, isNotDefined } from 'utils'
export const parseSampleResult =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
linkedTypebots: (Typebot | PublicTypebot)[]
) =>
async (
currentGroupId: string
): Promise<Record<string, string | boolean | undefined>> => {
const header = parseResultHeader(typebot, linkedTypebots)
const linkedInputBlocks = await extractLinkedInputBlocks(
typebot,
linkedTypebots
)(currentGroupId)
return {
message: 'This is a sample result, it has been generated ⬇️',
'Submitted at': new Date().toISOString(),
...parseResultSample(linkedInputBlocks, header),
}
}
const extractLinkedInputBlocks =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
linkedTypebots: (Typebot | PublicTypebot)[]
) =>
async (
currentGroupId?: string,
direction: 'backward' | 'forward' = 'backward'
): Promise<InputBlock[]> => {
const previousLinkedTypebotBlocks = walkEdgesAndExtract(
'linkedBot',
direction,
typebot
)({
groupId: currentGroupId,
}) as TypebotLinkBlock[]
const linkedBotInputs =
previousLinkedTypebotBlocks.length > 0
? await Promise.all(
previousLinkedTypebotBlocks.map((linkedBot) =>
extractLinkedInputBlocks(
linkedTypebots.find((t) =>
'typebotId' in t
? t.typebotId === linkedBot.options.typebotId
: t.id === linkedBot.options.typebotId
) as Typebot | PublicTypebot,
linkedTypebots
)(linkedBot.options.groupId, 'forward')
)
)
: []
return (
walkEdgesAndExtract(
'input',
direction,
typebot
)({
groupId: currentGroupId,
}) as InputBlock[]
).concat(linkedBotInputs.flatMap((l) => l))
}
const parseResultSample = (
inputBlocks: InputBlock[],
headerCells: ResultHeaderCell[]
) =>
headerCells.reduce<Record<string, string | boolean | undefined>>(
(resultSample, cell) => {
const inputBlock = inputBlocks.find((inputBlock) =>
cell.blocks?.some((block) => block.id === inputBlock.id)
)
if (isNotDefined(inputBlock)) {
if (cell.variableIds)
return {
...resultSample,
[cell.label]: 'content',
}
return resultSample
}
const value = getSampleValue(inputBlock)
return {
...resultSample,
[cell.label]: value,
}
},
{}
)
const getSampleValue = (block: InputBlock) => {
switch (block.type) {
case InputBlockType.CHOICE:
return block.options.isMultipleChoice
? block.items.map((i) => i.content).join(', ')
: block.items[0]?.content ?? 'Item'
case InputBlockType.DATE:
return new Date().toUTCString()
case InputBlockType.EMAIL:
return 'test@email.com'
case InputBlockType.NUMBER:
return '20'
case InputBlockType.PHONE:
return '+33665566773'
case InputBlockType.TEXT:
return 'answer value'
case InputBlockType.URL:
return 'https://test.com'
}
}
const walkEdgesAndExtract =
(
type: 'input' | 'linkedBot',
direction: 'backward' | 'forward',
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>
) =>
({ groupId }: { groupId?: string }): Block[] => {
const currentGroupId =
groupId ??
(typebot.groups.find((b) => b.blocks[0].type === 'start')?.id as string)
const blocksInGroup = extractBlocksInGroup(
type,
typebot
)({
groupId: currentGroupId,
})
const otherGroupIds = getGroupIds(typebot, direction)(currentGroupId)
return [
...blocksInGroup,
...otherGroupIds.flatMap((groupId) =>
extractBlocksInGroup(type, typebot)({ groupId })
),
]
}
const getGroupIds =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
direction: 'backward' | 'forward',
existingGroupIds?: string[]
) =>
(groupId: string): string[] => {
const groups = typebot.edges.reduce<string[]>((groupIds, edge) => {
if (direction === 'forward')
return (!existingGroupIds ||
!existingGroupIds?.includes(edge.to.groupId)) &&
edge.from.groupId === groupId
? [...groupIds, edge.to.groupId]
: groupIds
return (!existingGroupIds ||
!existingGroupIds.includes(edge.from.groupId)) &&
edge.to.groupId === groupId
? [...groupIds, edge.from.groupId]
: groupIds
}, [])
const newGroups = [...(existingGroupIds ?? []), ...groups]
return groups.concat(
groups.flatMap(getGroupIds(typebot, direction, newGroups))
)
}
const extractBlocksInGroup =
(
type: 'input' | 'linkedBot',
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>
) =>
({ groupId, blockId }: { groupId: string; blockId?: string }) => {
const currentGroup = typebot.groups.find(byId(groupId))
if (!currentGroup) return []
const blocks: Block[] = []
for (const block of currentGroup.blocks) {
if (block.id === blockId) break
if (type === 'input' && isInputBlock(block)) blocks.push(block)
if (type === 'linkedBot' && block.type === LogicBlockType.TYPEBOT_LINK)
blocks.push(block)
}
return blocks
}

View File

@ -0,0 +1,198 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { HttpMethod } from 'models'
import {
createWebhook,
deleteTypebots,
deleteWebhooks,
importTypebotInDatabase,
} from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
import { apiToken } from 'utils/playwright/databaseSetup'
import { getTestAsset } from '@/test/utils/playwright'
test.describe('Bot', () => {
const typebotId = cuid()
test.beforeEach(async () => {
await importTypebotInDatabase(getTestAsset('typebots/webhook.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
await createWebhook(typebotId, {
id: 'failing-webhook',
url: 'http://localhost:3001/api/mock/fail',
method: HttpMethod.POST,
})
await createWebhook(typebotId, {
id: 'partial-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{
"name": "{{Name}}",
"age": {{Age}},
"gender": "{{Gender}}"
}`,
})
await createWebhook(typebotId, {
id: 'full-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{{Full body}}`,
})
})
test.afterEach(async () => {
await deleteTypebots([typebotId])
await deleteWebhooks([
'failing-webhook',
'partial-body-webhook',
'full-body-webhook',
])
})
test('should execute webhooks properly', async ({ page }) => {
await page.goto(`/${typebotId}-public`)
await typebotViewer(page).locator('text=Send failing webhook').click()
await typebotViewer(page)
.locator('[placeholder="Type a name..."]')
.fill('John')
await typebotViewer(page).locator('text="Send"').click()
await typebotViewer(page)
.locator('[placeholder="Type an age..."]')
.fill('30')
await typebotViewer(page).locator('text="Send"').click()
await typebotViewer(page).locator('text="Male"').click()
await expect(
typebotViewer(page).getByText('{"name":"John","age":25,"gender":"male"}')
).toBeVisible()
await expect(
typebotViewer(page).getByText('{"name":"John","age":30,"gender":"Male"}')
).toBeVisible()
await page.goto(`http://localhost:3000/typebots/${typebotId}/results`)
await page.click('text="See logs"')
await expect(
page.locator('text="Webhook successfuly executed." >> nth=1')
).toBeVisible()
await expect(page.locator('text="Webhook returned an error"')).toBeVisible()
})
})
test.describe('API', () => {
const typebotId = 'webhook-flow'
test.beforeAll(async () => {
try {
await importTypebotInDatabase(getTestAsset('typebots/api.json'), {
id: typebotId,
})
await createWebhook(typebotId)
} catch (err) {
console.log(err)
}
})
test('can list typebots', async ({ request }) => {
expect((await request.get(`/api/typebots`)).status()).toBe(401)
const response = await request.get(`/api/typebots`, {
headers: { Authorization: `Bearer ${apiToken}` },
})
const { typebots } = (await response.json()) as { typebots: unknown[] }
expect(typebots.length).toBeGreaterThanOrEqual(1)
expect(typebots[0]).toMatchObject({
id: typebotId,
publishedTypebotId: null,
name: 'My typebot',
})
})
test('can get webhook blocks', async ({ request }) => {
expect(
(await request.get(`/api/typebots/${typebotId}/webhookBlocks`)).status()
).toBe(401)
const response = await request.get(
`/api/typebots/${typebotId}/webhookBlocks`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const { blocks } = await response.json()
expect(blocks).toHaveLength(1)
expect(blocks[0]).toEqual({
blockId: 'webhookBlock',
name: 'Webhook > webhookBlock',
})
})
test('can subscribe webhook', async ({ request }) => {
expect(
(
await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/subscribeWebhook`,
{ data: { url: 'https://test.com' } }
)
).status()
).toBe(401)
const response = await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/subscribeWebhook`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
data: { url: 'https://test.com' },
}
)
const body = await response.json()
expect(body).toEqual({
message: 'success',
})
})
test('can unsubscribe webhook', async ({ request }) => {
expect(
(
await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/unsubscribeWebhook`
)
).status()
).toBe(401)
const response = await request.post(
`/api/typebots/${typebotId}/blocks/webhookBlock/unsubscribeWebhook`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const body = await response.json()
expect(body).toEqual({
message: 'success',
})
})
test('can get a sample result', async ({ request }) => {
expect(
(
await request.get(
`/api/typebots/${typebotId}/blocks/webhookBlock/sampleResult`
)
).status()
).toBe(401)
const response = await request.get(
`/api/typebots/${typebotId}/blocks/webhookBlock/sampleResult`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const data = await response.json()
expect(data).toMatchObject({
message: 'This is a sample result, it has been generated ⬇️',
Welcome: 'Hi!',
Email: 'test@email.com',
Name: 'answer value',
Services: 'Website dev, Content Marketing, Social Media, UI / UX Design',
'Additional information': 'answer value',
})
})
})

View File

@ -0,0 +1,48 @@
import { Credentials as CredentialsFromDb } from 'db'
import { OAuth2Client, Credentials } from 'google-auth-library'
import { GoogleSheetsCredentialsData } from 'models'
import { isDefined } from 'utils'
import { decrypt, encrypt } from 'utils/api'
import prisma from './prisma'
export const getAuthenticatedGoogleClient = async (
credentialsId: string
): Promise<OAuth2Client | undefined> => {
const credentials = (await prisma.credentials.findFirst({
where: { id: credentialsId },
})) as CredentialsFromDb | undefined
if (!credentials) return
const data = decrypt(
credentials.data,
credentials.iv
) as GoogleSheetsCredentialsData
const oauth2Client = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
`${process.env.NEXTAUTH_URL}/api/credentials/google-sheets/callback`
)
oauth2Client.setCredentials(data)
oauth2Client.on('tokens', updateTokens(credentialsId, data))
return oauth2Client
}
const updateTokens =
(credentialsId: string, existingCredentials: GoogleSheetsCredentialsData) =>
async (credentials: Credentials) => {
if (
isDefined(existingCredentials.id_token) &&
credentials.id_token !== existingCredentials.id_token
)
return
const newCredentials: GoogleSheetsCredentialsData = {
...existingCredentials,
expiry_date: credentials.expiry_date,
access_token: credentials.access_token,
}
const { encryptedData, iv } = encrypt(newCredentials)
await prisma.credentials.update({
where: { id: credentialsId },
data: { data: encryptedData, iv },
})
}

View File

@ -0,0 +1,15 @@
import { PrismaClient } from 'db'
declare const global: { prisma: PrismaClient }
let prisma: PrismaClient
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient()
} else {
if (!global.prisma) {
global.prisma = new PrismaClient()
}
prisma = global.prisma
}
export default prisma

View File

@ -0,0 +1,120 @@
import { IncomingMessage } from 'http'
import { ErrorPage } from '@/components/ErrorPage'
import { NotFoundPage } from '@/components/NotFoundPage'
import { GetServerSideProps, GetServerSidePropsContext } from 'next'
import sanitizeHtml from 'sanitize-html'
import { env, getViewerUrl, isDefined, isNotDefined, omit } from 'utils'
import { TypebotPage, TypebotPageProps } from '../components/TypebotPage'
import prisma from '../lib/prisma'
export const getServerSideProps: GetServerSideProps = async (
context: GetServerSidePropsContext
) => {
const isIE = /MSIE|Trident/.test(context.req.headers['user-agent'] ?? '')
const pathname = context.resolvedUrl.split('?')[0]
const { host, forwardedHost } = getHost(context.req)
try {
if (!host) return { props: {} }
const viewerUrls = (getViewerUrl({ returnAll: true }) ?? '').split(',')
const isMatchingViewerUrl =
env('E2E_TEST') === 'true'
? true
: viewerUrls.some(
(url) =>
host.split(':')[0].includes(url.split('//')[1].split(':')[0]) ||
(forwardedHost &&
forwardedHost
.split(':')[0]
.includes(url.split('//')[1].split(':')[0]))
)
const customDomain = `${forwardedHost ?? host}${
pathname === '/' ? '' : pathname
}`
const publishedTypebot = isMatchingViewerUrl
? await getTypebotFromPublicId(context.query.publicId?.toString())
: await getTypebotFromCustomDomain(customDomain)
if (!publishedTypebot)
console.log(
isMatchingViewerUrl
? `Couldn't find publicId: ${context.query.publicId?.toString()}`
: `Couldn't find customDomain: ${customDomain}`
)
const headCode = publishedTypebot?.settings.metadata.customHeadCode
return {
props: {
publishedTypebot,
isIE,
url: `https://${forwardedHost ?? host}${pathname}`,
customHeadCode:
isDefined(headCode) && headCode !== ''
? sanitizeHtml(headCode, {
allowedTags: ['script', 'meta'],
allowedAttributes: {
meta: ['name', 'content'],
},
})
: null,
},
}
} catch (err) {
console.error(err)
}
return {
props: {
isIE,
url: `https://${forwardedHost ?? host}${pathname}`,
},
}
}
const getTypebotFromPublicId = async (
publicId?: string
): Promise<TypebotPageProps['publishedTypebot'] | null> => {
if (!publicId) return null
const publishedTypebot = await prisma.publicTypebot.findFirst({
where: { typebot: { publicId } },
include: {
typebot: { select: { name: true, isClosed: true, isArchived: true } },
},
})
if (isNotDefined(publishedTypebot)) return null
return omit(
publishedTypebot,
'createdAt',
'updatedAt'
) as TypebotPageProps['publishedTypebot']
}
const getTypebotFromCustomDomain = async (
customDomain: string
): Promise<TypebotPageProps['publishedTypebot'] | null> => {
const publishedTypebot = await prisma.publicTypebot.findFirst({
where: { typebot: { customDomain } },
include: {
typebot: { select: { name: true, isClosed: true, isArchived: true } },
},
})
if (isNotDefined(publishedTypebot)) return null
return omit(
publishedTypebot,
'createdAt',
'updatedAt'
) as TypebotPageProps['publishedTypebot']
}
const getHost = (
req?: IncomingMessage
): { host?: string; forwardedHost?: string } => ({
host: req?.headers ? req.headers.host : window.location.host,
forwardedHost: req?.headers['x-forwarded-host'] as string | undefined,
})
const App = ({ publishedTypebot, ...props }: TypebotPageProps) => {
if (!publishedTypebot || publishedTypebot.typebot.isArchived)
return <NotFoundPage />
if (publishedTypebot.typebot.isClosed)
return <ErrorPage error={new Error('This bot is now closed')} />
return <TypebotPage publishedTypebot={publishedTypebot} {...props} />
}
export default App

View File

@ -0,0 +1,15 @@
import React from 'react'
import '../assets/styles.css'
type Props = {
Component: React.ComponentType
pageProps: {
[key: string]: unknown
}
}
export default function MyApp({ Component, pageProps }: Props): JSX.Element {
const { ...componentProps } = pageProps
return <Component {...componentProps} />
}

View File

@ -0,0 +1,16 @@
/* eslint-disable @next/next/no-sync-scripts */
import { Html, Head, Main, NextScript } from 'next/document'
const Document = () => (
<Html>
<Head>
<script src="/__env.js" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
export default Document

View File

@ -0,0 +1,76 @@
import NextErrorComponent from 'next/error'
import * as Sentry from '@sentry/nextjs'
import { NextPageContext } from 'next'
const MyError = ({
statusCode,
hasGetInitialPropsRun,
err,
}: {
statusCode: number
hasGetInitialPropsRun: boolean
err: Error
}) => {
if (!hasGetInitialPropsRun && err) {
// getInitialProps is not called in case of
// https://github.com/vercel/next.js/issues/8592. As a workaround, we pass
// err via _app.js so it can be captured
Sentry.captureException(err)
// Flushing is not required in this case as it only happens on the client
}
return <NextErrorComponent statusCode={statusCode} />
}
MyError.getInitialProps = async (context: NextPageContext) => {
const errorInitialProps = await NextErrorComponent.getInitialProps(context)
const { res, err, asPath } = context
// Workaround for https://github.com/vercel/next.js/issues/8592, mark when
// getInitialProps has run
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
errorInitialProps.hasGetInitialPropsRun = true
// Returning early because we don't want to log 404 errors to Sentry.
if (res?.statusCode === 404) {
return errorInitialProps
}
// Running on the server, the response object (`res`) is available.
//
// Next.js will pass an err on the server if a page's data fetching methods
// threw or returned a Promise that rejected
//
// Running on the client (browser), Next.js will provide an err if:
//
// - a page's `getInitialProps` threw or returned a Promise that rejected
// - an exception was thrown somewhere in the React lifecycle (render,
// componentDidMount, etc) that was caught by Next.js's React Error
// Boundary. Read more about what types of exceptions are caught by Error
// Boundaries: https://reactjs.org/docs/error-boundaries.html
if (err) {
Sentry.captureException(err)
// Flushing before returning is necessary if deploying to Vercel, see
// https://vercel.com/docs/platform/limits#streaming-responses
await Sentry.flush(2000)
return errorInitialProps
}
// If this point is reached, getInitialProps was called without any
// information about what the error might be. This is unexpected and may
// indicate a bug introduced in Next.js, so record it in Sentry
Sentry.captureException(
new Error(`_error.js getInitialProps missing data at path: ${asPath}`)
)
await Sentry.flush(2000)
return errorInitialProps
}
export default MyError

View File

@ -0,0 +1,128 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { badRequest, initMiddleware, methodNotAllowed } from 'utils/api'
import { hasValue } from 'utils'
import { GoogleSpreadsheet } from 'google-spreadsheet'
import { Cell } from 'models'
import Cors from 'cors'
import { withSentry } from '@sentry/nextjs'
import { getAuthenticatedGoogleClient } from '@/lib/google-sheets'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
const cors = initMiddleware(Cors())
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res)
const resultId = req.query.resultId as string | undefined
if (req.method === 'GET') {
const spreadsheetId = req.query.spreadsheetId as string
const sheetId = req.query.sheetId as string
const credentialsId = req.query.credentialsId as string | undefined
if (!hasValue(credentialsId)) return badRequest(res)
const referenceCell = {
column: req.query['referenceCell[column]'],
value: req.query['referenceCell[value]'],
} as Cell
const extractingColumns = getExtractingColumns(
req.query.columns as string[] | string | undefined
)
if (!extractingColumns) return badRequest(res)
const doc = new GoogleSpreadsheet(spreadsheetId)
const client = await getAuthenticatedGoogleClient(credentialsId)
if (!client)
return res.status(404).send("Couldn't find credentials in database")
doc.useOAuth2Client(client)
await doc.loadInfo()
const sheet = doc.sheetsById[sheetId]
try {
const rows = await sheet.getRows()
const row = rows.find(
(row) => row[referenceCell.column as string] === referenceCell.value
)
if (!row) {
await saveErrorLog(resultId, "Couldn't find reference cell")
return res.status(404).send({ message: "Couldn't find row" })
}
const response = {
...extractingColumns.reduce(
(obj, column) => ({ ...obj, [column]: row[column] }),
{}
),
}
await saveSuccessLog(resultId, 'Succesfully fetched spreadsheet data')
return res.send(response)
} catch (err) {
await saveErrorLog(resultId, "Couldn't fetch spreadsheet data", err)
return res.status(500).send(err)
}
}
if (req.method === 'POST') {
const spreadsheetId = req.query.spreadsheetId as string
const sheetId = req.query.sheetId as string
const { credentialsId, values } = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as {
credentialsId?: string
values: { [key: string]: string }
}
if (!hasValue(credentialsId)) return badRequest(res)
const doc = new GoogleSpreadsheet(spreadsheetId)
const auth = await getAuthenticatedGoogleClient(credentialsId)
if (!auth)
return res.status(404).send("Couldn't find credentials in database")
doc.useOAuth2Client(auth)
try {
await doc.loadInfo()
const sheet = doc.sheetsById[sheetId]
await sheet.addRow(values)
await saveSuccessLog(resultId, 'Succesfully inserted row')
return res.send({ message: 'Success' })
} catch (err) {
await saveErrorLog(resultId, "Couldn't fetch spreadsheet data", err)
return res.status(500).send(err)
}
}
if (req.method === 'PATCH') {
const spreadsheetId = req.query.spreadsheetId as string
const sheetId = req.query.sheetId as string
const { credentialsId, values, referenceCell } = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as {
credentialsId?: string
referenceCell: Cell
values: { [key: string]: string }
}
if (!hasValue(credentialsId)) return badRequest(res)
const doc = new GoogleSpreadsheet(spreadsheetId)
const auth = await getAuthenticatedGoogleClient(credentialsId)
if (!auth)
return res.status(404).send("Couldn't find credentials in database")
doc.useOAuth2Client(auth)
try {
await doc.loadInfo()
const sheet = doc.sheetsById[sheetId]
const rows = await sheet.getRows()
const updatingRowIndex = rows.findIndex(
(row) => row[referenceCell.column as string] === referenceCell.value
)
if (updatingRowIndex === -1)
return res.status(404).send({ message: "Couldn't find row to update" })
for (const key in values) {
rows[updatingRowIndex][key] = values[key]
}
await rows[updatingRowIndex].save()
await saveSuccessLog(resultId, 'Succesfully updated row')
return res.send({ message: 'Success' })
} catch (err) {
await saveErrorLog(resultId, "Couldn't fetch spreadsheet data", err)
return res.status(500).send(err)
}
}
return methodNotAllowed(res)
}
const getExtractingColumns = (columns: string | string[] | undefined) => {
if (typeof columns === 'string') return [columns]
if (Array.isArray(columns)) return columns
}
export default withSentry(handler)

View File

@ -0,0 +1,135 @@
import { NextApiRequest, NextApiResponse } from 'next'
import {
badRequest,
decrypt,
forbidden,
initMiddleware,
methodNotAllowed,
} from 'utils/api'
import Stripe from 'stripe'
import Cors from 'cors'
import { withSentry } from '@sentry/nextjs'
import { PaymentInputOptions, StripeCredentialsData, Variable } from 'models'
import prisma from '@/lib/prisma'
import { parseVariables } from 'bot-engine'
const cors = initMiddleware(Cors())
const currencySymbols: { [key: string]: string } = {
USD: '$',
EUR: '€',
CRC: '₡',
GBP: '£',
ILS: '₪',
INR: '₹',
JPY: '¥',
KRW: '₩',
NGN: '₦',
PHP: '₱',
PLN: 'zł',
PYG: '₲',
THB: '฿',
UAH: '₴',
VND: '₫',
}
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res)
if (req.method === 'POST') {
const { inputOptions, isPreview, variables } = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as {
inputOptions: PaymentInputOptions
isPreview: boolean
variables: Variable[]
}
if (!inputOptions.credentialsId) return forbidden(res)
const stripeKeys = await getStripeInfo(inputOptions.credentialsId)
if (!stripeKeys) return forbidden(res)
const stripe = new Stripe(
isPreview && stripeKeys?.test?.secretKey
? stripeKeys.test.secretKey
: stripeKeys.live.secretKey,
{ apiVersion: '2022-08-01' }
)
const amount =
Number(parseVariables(variables)(inputOptions.amount)) *
(isZeroDecimalCurrency(inputOptions.currency) ? 1 : 100)
if (isNaN(amount)) return badRequest(res)
// Create a PaymentIntent with the order amount and currency
const receiptEmail = parseVariables(variables)(
inputOptions.additionalInformation?.email
)
try {
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: inputOptions.currency,
receipt_email: receiptEmail === '' ? undefined : receiptEmail,
automatic_payment_methods: {
enabled: true,
},
})
return res.send({
clientSecret: paymentIntent.client_secret,
publicKey:
isPreview && stripeKeys.test?.publicKey
? stripeKeys.test.publicKey
: stripeKeys.live.publicKey,
amountLabel: `${
amount / (isZeroDecimalCurrency(inputOptions.currency) ? 1 : 100)
}${
currencySymbols[inputOptions.currency] ?? ` ${inputOptions.currency}`
}`,
})
} catch (err) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const error = err as any
return 'raw' in error
? res.status(error.raw.statusCode).send({
error: {
name: `${error.raw.type} ${error.raw.param}`,
message: error.raw.message,
},
})
: res.status(500).send({
error,
})
}
}
return methodNotAllowed(res)
}
const getStripeInfo = async (
credentialsId: string
): Promise<StripeCredentialsData | undefined> => {
const credentials = await prisma.credentials.findUnique({
where: { id: credentialsId },
})
if (!credentials) return
return decrypt(credentials.data, credentials.iv) as StripeCredentialsData
}
// https://stripe.com/docs/currencies#zero-decimal
const isZeroDecimalCurrency = (currency: string) =>
[
'BIF',
'CLP',
'DJF',
'GNF',
'JPY',
'KMF',
'KRW',
'MGA',
'PYG',
'RWF',
'UGX',
'VND',
'VUV',
'XAF',
'XOF',
'XPF',
].includes(currency)
export default withSentry(handler)

View File

@ -0,0 +1,15 @@
import { withSentry } from '@sentry/nextjs'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils/api'
const handler = (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
return res.status(500).json({
statusCode: 500,
statusMessage: 'Fail',
})
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,15 @@
import { withSentry } from '@sentry/nextjs'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils/api'
const handler = (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
return res.status(200).json({
statusCode: 200,
statusMessage: 'OK',
})
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,22 @@
import { withSentry } from '@sentry/nextjs'
import prisma from '@/lib/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import Cors from 'cors'
import { initMiddleware, methodNotAllowed, notFound } from 'utils/api'
const cors = initMiddleware(Cors())
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res)
if (req.method === 'GET') {
const typebotId = req.query.typebotId as string
const typebot = await prisma.publicTypebot.findUnique({
where: { typebotId },
})
if (!typebot) return notFound(res)
return res.send({ typebot })
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,20 @@
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { withSentry } from '@sentry/nextjs'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
const typebots = await prisma.typebot.findMany({
where: { workspace: { members: { some: { userId: user.id } } } },
select: { name: true, publishedTypebotId: true, id: true },
})
return res.send({ typebots })
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,232 @@
import {
defaultWebhookAttributes,
KeyValue,
PublicTypebot,
ResultValues,
Typebot,
Variable,
Webhook,
WebhookOptions,
WebhookResponse,
WebhookBlock,
HttpMethod,
} from 'models'
import { parseVariables } from 'bot-engine'
import { NextApiRequest, NextApiResponse } from 'next'
import got, { Method, Headers, HTTPError } from 'got'
import { byId, omit, parseAnswers } from 'utils'
import { initMiddleware, methodNotAllowed, notFound } from 'utils/api'
import { stringify } from 'qs'
import { withSentry } from '@sentry/nextjs'
import Cors from 'cors'
import prisma from '@/lib/prisma'
import { getLinkedTypebots } from '@/features/typebotLink/api'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
import { parseSampleResult } from '@/features/webhook/api'
const cors = initMiddleware(Cors())
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res)
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const blockId = req.query.blockId as string
const resultId = req.query.resultId as string | undefined
const { resultValues, variables } = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as {
resultValues: ResultValues | undefined
variables: Variable[]
}
const typebot = (await prisma.typebot.findUnique({
where: { id: typebotId },
include: { webhooks: true },
})) as unknown as (Typebot & { webhooks: Webhook[] }) | null
if (!typebot) return notFound(res)
const block = typebot.groups
.flatMap((g) => g.blocks)
.find(byId(blockId)) as WebhookBlock
const webhook = typebot.webhooks.find(byId(block.webhookId))
if (!webhook)
return res
.status(404)
.send({ statusCode: 404, data: { message: `Couldn't find webhook` } })
const preparedWebhook = prepareWebhookAttributes(webhook, block.options)
const result = await executeWebhook(typebot)(
preparedWebhook,
variables,
block.groupId,
resultValues,
resultId
)
return res.status(200).send(result)
}
return methodNotAllowed(res)
}
const prepareWebhookAttributes = (
webhook: Webhook,
options: WebhookOptions
): Webhook => {
if (options.isAdvancedConfig === false) {
return { ...webhook, body: '{{state}}', ...defaultWebhookAttributes }
} else if (options.isCustomBody === false) {
return { ...webhook, body: '{{state}}' }
}
return webhook
}
const checkIfBodyIsAVariable = (body: string) => /^{{.+}}$/.test(body)
export const executeWebhook =
(typebot: Typebot) =>
async (
webhook: Webhook,
variables: Variable[],
groupId: string,
resultValues?: ResultValues,
resultId?: string
): Promise<WebhookResponse> => {
if (!webhook.url || !webhook.method)
return {
statusCode: 400,
data: { message: `Webhook doesn't have url or method` },
}
const basicAuth: { username?: string; password?: string } = {}
const basicAuthHeaderIdx = webhook.headers.findIndex(
(h) =>
h.key?.toLowerCase() === 'authorization' &&
h.value?.toLowerCase()?.includes('basic')
)
const isUsernamePasswordBasicAuth =
basicAuthHeaderIdx !== -1 &&
webhook.headers[basicAuthHeaderIdx].value?.includes(':')
if (isUsernamePasswordBasicAuth) {
const [username, password] =
webhook.headers[basicAuthHeaderIdx].value?.slice(6).split(':') ?? []
basicAuth.username = username
basicAuth.password = password
webhook.headers.splice(basicAuthHeaderIdx, 1)
}
const headers = convertKeyValueTableToObject(webhook.headers, variables) as
| Headers
| undefined
const queryParams = stringify(
convertKeyValueTableToObject(webhook.queryParams, variables)
)
const contentType = headers ? headers['Content-Type'] : undefined
const linkedTypebots = await getLinkedTypebots(typebot)
const bodyContent = await getBodyContent(
typebot,
linkedTypebots
)({
body: webhook.body,
resultValues,
groupId,
})
const { data: body, isJson } =
bodyContent && webhook.method !== HttpMethod.GET
? safeJsonParse(
parseVariables(variables, {
escapeForJson: !checkIfBodyIsAVariable(bodyContent),
})(bodyContent)
)
: { data: undefined, isJson: false }
const request = {
url: parseVariables(variables)(
webhook.url + (queryParams !== '' ? `?${queryParams}` : '')
),
method: webhook.method as Method,
headers,
...basicAuth,
json:
contentType !== 'x-www-form-urlencoded' && body && isJson
? body
: undefined,
form: contentType === 'x-www-form-urlencoded' && body ? body : undefined,
body: body && !isJson ? body : undefined,
}
try {
const response = await got(request.url, omit(request, 'url'))
await saveSuccessLog(resultId, 'Webhook successfuly executed.', {
statusCode: response.statusCode,
request,
response: safeJsonParse(response.body).data,
})
return {
statusCode: response.statusCode,
data: safeJsonParse(response.body).data,
}
} catch (error) {
if (error instanceof HTTPError) {
const response = {
statusCode: error.response.statusCode,
data: safeJsonParse(error.response.body as string).data,
}
await saveErrorLog(resultId, 'Webhook returned an error', {
request,
response,
})
return response
}
const response = {
statusCode: 500,
data: { message: `Error from Typebot server: ${error}` },
}
console.error(error)
await saveErrorLog(resultId, 'Webhook failed to execute', {
request,
response,
})
return response
}
}
const getBodyContent =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
linkedTypebots: (Typebot | PublicTypebot)[]
) =>
async ({
body,
resultValues,
groupId,
}: {
body?: string | null
resultValues?: ResultValues
groupId: string
}): Promise<string | undefined> => {
if (!body) return
return body === '{{state}}'
? JSON.stringify(
resultValues
? parseAnswers(typebot, linkedTypebots)(resultValues)
: await parseSampleResult(typebot, linkedTypebots)(groupId)
)
: body
}
const convertKeyValueTableToObject = (
keyValues: KeyValue[] | undefined,
variables: Variable[]
) => {
if (!keyValues) return
return keyValues.reduce((object, item) => {
if (!item.key) return {}
return {
...object,
[item.key]: parseVariables(variables)(item.value ?? ''),
}
}, {})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const safeJsonParse = (json: string): { data: any; isJson: boolean } => {
try {
return { data: JSON.parse(json), isJson: true }
} catch (err) {
return { data: json, isJson: false }
}
}
export default withSentry(handler)

View File

@ -0,0 +1,34 @@
import { authenticateUser } from '@/features/auth/api'
import { getLinkedTypebots } from '@/features/typebotLink/api'
import { parseSampleResult } from '@/features/webhook/api'
import prisma from '@/lib/prisma'
import { Typebot } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
if (req.method === 'GET') {
const typebotId = req.query.typebotId as string
const blockId = req.query.blockId as string
const typebot = (await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
})) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
const block = typebot.groups
.flatMap((g) => g.blocks)
.find((s) => s.id === blockId)
if (!block) return res.status(404).send({ message: 'Group not found' })
const linkedTypebots = await getLinkedTypebots(typebot, user)
return res.send(
await parseSampleResult(typebot, linkedTypebots)(block.groupId)
)
}
methodNotAllowed(res)
}
export default handler

View File

@ -0,0 +1,70 @@
import prisma from '@/lib/prisma'
import {
defaultWebhookAttributes,
ResultValues,
Typebot,
Variable,
Webhook,
WebhookOptions,
WebhookBlock,
} from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { initMiddleware, methodNotAllowed, notFound } from 'utils/api'
import { byId } from 'utils'
import { withSentry } from '@sentry/nextjs'
import Cors from 'cors'
import { executeWebhook } from '../../executeWebhook'
const cors = initMiddleware(Cors())
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res)
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const groupId = req.query.groupId as string
const blockId = req.query.blockId as string
const resultId = req.query.resultId as string | undefined
const { resultValues, variables } = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as {
resultValues: ResultValues | undefined
variables: Variable[]
}
const typebot = (await prisma.typebot.findUnique({
where: { id: typebotId },
include: { webhooks: true },
})) as unknown as (Typebot & { webhooks: Webhook[] }) | null
if (!typebot) return notFound(res)
const block = typebot.groups
.find(byId(groupId))
?.blocks.find(byId(blockId)) as WebhookBlock
const webhook = typebot.webhooks.find(byId(block.webhookId))
if (!webhook)
return res
.status(404)
.send({ statusCode: 404, data: { message: `Couldn't find webhook` } })
const preparedWebhook = prepareWebhookAttributes(webhook, block.options)
const result = await executeWebhook(typebot)(
preparedWebhook,
variables,
groupId,
resultValues,
resultId
)
return res.status(200).send(result)
}
return methodNotAllowed(res)
}
const prepareWebhookAttributes = (
webhook: Webhook,
options: WebhookOptions
): Webhook => {
if (options.isAdvancedConfig === false) {
return { ...webhook, body: '{{state}}', ...defaultWebhookAttributes }
} else if (options.isCustomBody === false) {
return { ...webhook, body: '{{state}}' }
}
return webhook
}
export default withSentry(handler)

View File

@ -0,0 +1,28 @@
import { authenticateUser } from '@/features/auth/api'
import { getLinkedTypebots } from '@/features/typebotLink/api'
import prisma from '@/lib/prisma'
import { Typebot } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils/api'
import { parseSampleResult } from '@/features/webhook/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
if (req.method === 'GET') {
const typebotId = req.query.typebotId as string
const groupId = req.query.groupId as string
const typebot = (await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
})) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
const linkedTypebots = await getLinkedTypebots(typebot, user)
return res.send(await parseSampleResult(typebot, linkedTypebots)(groupId))
}
methodNotAllowed(res)
}
export default handler

View File

@ -0,0 +1,47 @@
import { withSentry } from '@sentry/nextjs'
import { Typebot, WebhookBlock } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils/api'
import { byId } from 'utils'
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
if (req.method === 'POST') {
const body = req.body as Record<string, string>
if (!('url' in body))
return res.status(403).send({ message: 'url is missing in body' })
const { url } = body
const typebotId = req.query.typebotId as string
const groupId = req.query.groupId as string
const blockId = req.query.blockId as string
const typebot = (await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
})) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
try {
const { webhookId } = typebot.groups
.find(byId(groupId))
?.blocks.find(byId(blockId)) as WebhookBlock
await prisma.webhook.upsert({
where: { id: webhookId },
update: { url, body: '{{state}}', method: 'POST' },
create: { url, body: '{{state}}', method: 'POST', typebotId },
})
return res.send({ message: 'success' })
} catch (err) {
return res
.status(400)
.send({ message: "blockId doesn't point to a Webhook block" })
}
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,42 @@
import { withSentry } from '@sentry/nextjs'
import { Typebot, WebhookBlock } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils/api'
import { byId } from 'utils'
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const groupId = req.query.groupId as string
const blockId = req.query.blockId as string
const typebot = (await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
})) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
try {
const { webhookId } = typebot.groups
.find(byId(groupId))
?.blocks.find(byId(blockId)) as WebhookBlock
await prisma.webhook.update({
where: { id: webhookId },
data: { url: null },
})
return res.send({ message: 'success' })
} catch (err) {
return res
.status(400)
.send({ message: "blockId doesn't point to a Webhook block" })
}
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,168 @@
import { withSentry } from '@sentry/nextjs'
import { WorkspaceRole } from 'db'
import prisma from '@/lib/prisma'
import { InputBlockType, PublicTypebot } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { badRequest, generatePresignedUrl, methodNotAllowed } from 'utils/api'
import { byId, getStorageLimit, isDefined, env } from 'utils'
import {
sendAlmostReachedStorageLimitEmail,
sendReachedStorageLimitEmail,
} from 'emails'
const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8
const handler = async (
req: NextApiRequest,
res: NextApiResponse
): Promise<void> => {
res.setHeader('Access-Control-Allow-Origin', '*')
if (req.method === 'GET') {
if (
!process.env.S3_ENDPOINT ||
!process.env.S3_ACCESS_KEY ||
!process.env.S3_SECRET_KEY
)
return badRequest(
res,
'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY'
)
const filePath = req.query.filePath as string | undefined
const fileType = req.query.fileType as string | undefined
const typebotId = req.query.typebotId as string
const blockId = req.query.blockId as string
if (!filePath) return badRequest(res, 'Missing filePath or fileType')
// const hasReachedStorageLimit = await checkStorageLimit(typebotId)
const typebot = (await prisma.publicTypebot.findFirst({
where: { typebotId },
})) as unknown as PublicTypebot
const fileUploadBlock = typebot.groups
.flatMap((g) => g.blocks)
.find(byId(blockId))
if (fileUploadBlock?.type !== InputBlockType.FILE)
return badRequest(res, 'Not a file upload block')
const sizeLimit = fileUploadBlock.options.sizeLimit
? Math.min(fileUploadBlock.options.sizeLimit, 500)
: 10
const presignedUrl = generatePresignedUrl({
fileType,
filePath,
sizeLimit: sizeLimit * 1024 * 1024,
})
// TODO: enable storage limit on 1st of November 2022
return res.status(200).send({ presignedUrl, hasReachedStorageLimit: false })
}
return methodNotAllowed(res)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const checkStorageLimit = async (typebotId: string) => {
const typebot = await prisma.typebot.findFirst({
where: { id: typebotId },
include: {
workspace: {
select: {
id: true,
additionalStorageIndex: true,
plan: true,
storageLimitFirstEmailSentAt: true,
storageLimitSecondEmailSentAt: true,
customStorageLimit: true,
},
},
},
})
if (!typebot?.workspace) throw new Error('Workspace not found')
const { workspace } = typebot
const {
_sum: { storageUsed: totalStorageUsed },
} = await prisma.answer.aggregate({
where: {
storageUsed: { gt: 0 },
result: {
typebot: {
workspace: {
id: typebot?.workspaceId,
},
},
},
},
_sum: { storageUsed: true },
})
if (!totalStorageUsed) return false
const hasSentFirstEmail = workspace.storageLimitFirstEmailSentAt !== null
const hasSentSecondEmail = workspace.storageLimitSecondEmailSentAt !== null
const storageLimit = getStorageLimit(typebot.workspace)
const storageLimitBytes = storageLimit * 1024 * 1024 * 1024
if (
totalStorageUsed >= storageLimitBytes * LIMIT_EMAIL_TRIGGER_PERCENT &&
!hasSentFirstEmail &&
env('E2E_TEST') !== 'true'
)
await sendAlmostReachStorageLimitNotification({
workspaceId: workspace.id,
storageLimit,
})
if (
totalStorageUsed >= storageLimitBytes &&
!hasSentSecondEmail &&
env('E2E_TEST') !== 'true'
)
await sendReachStorageLimitNotification({
workspaceId: workspace.id,
storageLimit,
})
return totalStorageUsed >= storageLimitBytes
}
const sendAlmostReachStorageLimitNotification = async ({
workspaceId,
storageLimit,
}: {
workspaceId: string
storageLimit: number
}) => {
const members = await prisma.memberInWorkspace.findMany({
where: { role: WorkspaceRole.ADMIN, workspaceId },
include: { user: { select: { email: true } } },
})
await sendAlmostReachedStorageLimitEmail({
to: members.map((member) => member.user.email).filter(isDefined),
storageLimit,
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
})
await prisma.workspace.update({
where: { id: workspaceId },
data: { storageLimitFirstEmailSentAt: new Date() },
})
}
const sendReachStorageLimitNotification = async ({
workspaceId,
storageLimit,
}: {
workspaceId: string
storageLimit: number
}) => {
const members = await prisma.memberInWorkspace.findMany({
where: { role: WorkspaceRole.ADMIN, workspaceId },
include: { user: { select: { email: true } } },
})
await sendReachedStorageLimitEmail({
to: members.map((member) => member.user.email).filter(isDefined),
storageLimit,
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
})
await prisma.workspace.update({
where: { id: workspaceId },
data: { storageLimitSecondEmailSentAt: new Date() },
})
}
export default withSentry(handler)

View File

@ -0,0 +1,46 @@
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { withSentry } from '@sentry/nextjs'
import { Typebot, WebhookBlock } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { byId } from 'utils'
import { methodNotAllowed } from 'utils/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
if (req.method === 'POST') {
const body = req.body as Record<string, string>
if (!('url' in body))
return res.status(403).send({ message: 'url is missing in body' })
const { url } = body
const typebotId = req.query.typebotId as string
const blockId = req.query.blockId as string
const typebot = (await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
})) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
try {
const { webhookId } = typebot.groups
.flatMap((g) => g.blocks)
.find(byId(blockId)) as WebhookBlock
await prisma.webhook.upsert({
where: { id: webhookId },
update: { url, body: '{{state}}', method: 'POST' },
create: { url, body: '{{state}}', method: 'POST', typebotId },
})
return res.send({ message: 'success' })
} catch (err) {
return res
.status(400)
.send({ message: "groupId doesn't point to a Webhook block" })
}
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,41 @@
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { withSentry } from '@sentry/nextjs'
import { Typebot, WebhookBlock } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { byId } from 'utils'
import { methodNotAllowed } from 'utils/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const blockId = req.query.blockId as string
const typebot = (await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
})) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
try {
const { webhookId } = typebot.groups
.flatMap((g) => g.blocks)
.find(byId(blockId)) as WebhookBlock
await prisma.webhook.update({
where: { id: webhookId },
data: { url: null },
})
return res.send({ message: 'success' })
} catch (err) {
return res
.status(400)
.send({ message: "groupId doesn't point to a Webhook block" })
}
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,207 @@
import {
PublicTypebot,
ResultValues,
SendEmailOptions,
SmtpCredentialsData,
} from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { createTransport, getTestMessageUrl } from 'nodemailer'
import { isEmpty, isNotDefined, omit, parseAnswers } from 'utils'
import { methodNotAllowed, initMiddleware, decrypt } from 'utils/api'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
import Cors from 'cors'
import { withSentry } from '@sentry/nextjs'
import Mail from 'nodemailer/lib/mailer'
import { DefaultBotNotificationEmail, render } from 'emails'
import prisma from '@/lib/prisma'
import { getLinkedTypebots } from '@/features/typebotLink/api'
const cors = initMiddleware(Cors())
const defaultTransportOptions = {
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : false,
auth: {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
},
}
const defaultFrom = {
name: process.env.SMTP_FROM?.split(' <')[0].replace(/"/g, ''),
email: process.env.SMTP_FROM?.match(/\<(.*)\>/)?.pop(),
}
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res)
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const resultId = req.query.resultId as string | undefined
const {
credentialsId,
recipients,
body,
subject,
cc,
bcc,
replyTo,
isBodyCode,
isCustomBody,
resultValues,
fileUrls,
} = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as SendEmailOptions & {
resultValues: ResultValues
fileUrls?: string
}
const { name: replyToName } = parseEmailRecipient(replyTo)
const { host, port, isTlsEnabled, username, password, from } =
(await getEmailInfo(credentialsId)) ?? {}
if (!from)
return res.status(404).send({ message: "Couldn't find credentials" })
const transportConfig = {
host,
port,
secure: isTlsEnabled ?? undefined,
auth: {
user: username,
pass: password,
},
}
const emailBody = await getEmailBody({
body,
isCustomBody,
isBodyCode,
typebotId,
resultValues,
})
if (!emailBody) {
await saveErrorLog(resultId, 'Email not sent', {
transportConfig,
recipients,
subject,
cc,
bcc,
replyTo,
emailBody,
})
return res.status(404).send({ message: "Couldn't find email body" })
}
const transporter = createTransport(transportConfig)
const email: Mail.Options = {
from: `"${isEmpty(replyToName) ? from.name : replyToName}" <${
from.email
}>`,
cc,
bcc,
to: recipients,
replyTo,
subject,
attachments: fileUrls?.split(', ').map((url) => ({ path: url })),
...emailBody,
}
try {
const info = await transporter.sendMail(email)
await saveSuccessLog(resultId, 'Email successfully sent', {
transportConfig: {
...transportConfig,
auth: { user: transportConfig.auth.user, pass: '******' },
},
email,
})
return res.status(200).send({
message: 'Email sent!',
info,
previewUrl: getTestMessageUrl(info),
})
} catch (err) {
await saveErrorLog(resultId, 'Email not sent', {
transportConfig: {
...transportConfig,
auth: { user: transportConfig.auth.user, pass: '******' },
},
email,
error: err,
})
return res.status(500).send({
message: `Email not sent. Error: ${err}`,
})
}
}
return methodNotAllowed(res)
}
const getEmailInfo = async (
credentialsId: string
): Promise<SmtpCredentialsData | undefined> => {
if (credentialsId === 'default')
return {
host: defaultTransportOptions.host,
port: defaultTransportOptions.port,
username: defaultTransportOptions.auth.user,
password: defaultTransportOptions.auth.pass,
isTlsEnabled: undefined,
from: defaultFrom,
}
const credentials = await prisma.credentials.findUnique({
where: { id: credentialsId },
})
if (!credentials) return
return decrypt(credentials.data, credentials.iv) as SmtpCredentialsData
}
const getEmailBody = async ({
body,
isCustomBody,
isBodyCode,
typebotId,
resultValues,
}: { typebotId: string; resultValues: ResultValues } & Pick<
SendEmailOptions,
'isCustomBody' | 'isBodyCode' | 'body'
>): Promise<{ html?: string; text?: string } | undefined> => {
if (isCustomBody || (isNotDefined(isCustomBody) && !isEmpty(body)))
return {
html: isBodyCode ? body : undefined,
text: !isBodyCode ? body : undefined,
}
const typebot = (await prisma.publicTypebot.findUnique({
where: { typebotId },
})) as unknown as PublicTypebot
if (!typebot) return
const linkedTypebots = await getLinkedTypebots(typebot)
const answers = parseAnswers(typebot, linkedTypebots)(resultValues)
return {
html: render(
<DefaultBotNotificationEmail
resultsUrl={`${process.env.NEXTAUTH_URL}/typebots/${typebot.id}/results`}
answers={omit(answers, 'submittedAt')}
/>
).html,
}
}
const parseEmailRecipient = (
recipient?: string
): { email?: string; name?: string } => {
if (!recipient) return {}
if (recipient.includes('<')) {
const [name, email] = recipient.split('<')
return {
name: name.replace(/>/g, '').trim().replace(/"/g, ''),
email: email.replace('>', '').trim(),
}
}
return {
email: recipient,
}
}
export default withSentry(handler)

View File

@ -0,0 +1,168 @@
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { Workspace, WorkspaceRole } from 'db'
import {
sendAlmostReachedChatsLimitEmail,
sendReachedChatsLimitEmail,
} from 'emails'
import { ResultWithAnswers } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { env, getChatsLimit, isDefined } from 'utils'
import { methodNotAllowed } from 'utils/api'
const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
const typebotId = req.query.typebotId as string
const limit = Number(req.query.limit)
const results = (await prisma.result.findMany({
where: {
typebot: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
},
orderBy: { createdAt: 'desc' },
take: limit,
include: { answers: true },
})) as unknown as ResultWithAnswers[]
return res.send({ results })
}
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const result = await prisma.result.create({
data: {
typebotId,
isCompleted: false,
},
include: {
typebot: {
include: {
workspace: {
select: {
id: true,
plan: true,
additionalChatsIndex: true,
chatsLimitFirstEmailSentAt: true,
chatsLimitSecondEmailSentAt: true,
customChatsLimit: true,
},
},
},
},
},
})
// TODO: enable storage limit on 1st of November 2022
// const hasReachedLimit = await checkChatsUsage(result.typebot.workspace)
res.send({ result, hasReachedLimit: false })
return
}
methodNotAllowed(res)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const checkChatsUsage = async (
workspace: Pick<
Workspace,
| 'id'
| 'plan'
| 'additionalChatsIndex'
| 'chatsLimitFirstEmailSentAt'
| 'chatsLimitSecondEmailSentAt'
| 'customChatsLimit'
>
) => {
const chatsLimit = getChatsLimit(workspace)
if (chatsLimit === -1) return
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const lastDayOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0)
const firstDayOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1)
const chatsCount = await prisma.result.count({
where: {
typebot: { workspaceId: workspace.id },
hasStarted: true,
createdAt: { gte: firstDayOfMonth, lte: lastDayOfMonth },
},
})
const hasSentFirstEmail =
workspace.chatsLimitFirstEmailSentAt !== null &&
workspace.chatsLimitFirstEmailSentAt < firstDayOfNextMonth &&
workspace.chatsLimitFirstEmailSentAt > firstDayOfMonth
const hasSentSecondEmail =
workspace.chatsLimitSecondEmailSentAt !== null &&
workspace.chatsLimitSecondEmailSentAt < firstDayOfNextMonth &&
workspace.chatsLimitSecondEmailSentAt > firstDayOfMonth
if (
chatsCount >= chatsLimit * LIMIT_EMAIL_TRIGGER_PERCENT &&
!hasSentFirstEmail &&
env('E2E_TEST') !== 'true'
)
await sendAlmostReachChatsLimitNotification({
workspaceId: workspace.id,
chatsLimit,
})
if (
chatsCount >= chatsLimit &&
!hasSentSecondEmail &&
env('E2E_TEST') !== 'true'
)
await sendReachedAlertNotification({
workspaceId: workspace.id,
chatsLimit,
})
return chatsCount >= chatsLimit
}
const sendAlmostReachChatsLimitNotification = async ({
workspaceId,
chatsLimit,
}: {
workspaceId: string
chatsLimit: number
}) => {
const members = await prisma.memberInWorkspace.findMany({
where: { role: WorkspaceRole.ADMIN, workspaceId },
include: { user: { select: { email: true } } },
})
await sendAlmostReachedChatsLimitEmail({
to: members.map((member) => member.user.email).filter(isDefined),
chatsLimit,
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
})
await prisma.workspace.update({
where: { id: workspaceId },
data: { chatsLimitFirstEmailSentAt: new Date() },
})
}
const sendReachedAlertNotification = async ({
workspaceId,
chatsLimit,
}: {
workspaceId: string
chatsLimit: number
}) => {
const members = await prisma.memberInWorkspace.findMany({
where: { role: WorkspaceRole.ADMIN, workspaceId },
include: { user: { select: { email: true } } },
})
await sendReachedChatsLimitEmail({
to: members.map((member) => member.user.email).filter(isDefined),
chatsLimit,
url: `${process.env.NEXTAUTH_URL}/typebots?workspaceId=${workspaceId}`,
})
await prisma.workspace.update({
where: { id: workspaceId },
data: { chatsLimitSecondEmailSentAt: new Date() },
})
}
export default handler

View File

@ -0,0 +1,22 @@
import { withSentry } from '@sentry/nextjs'
import prisma from '@/lib/prisma'
import { Result } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'PATCH') {
const data = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as Result
const resultId = req.query.resultId as string
const result = await prisma.result.update({
where: { id: resultId },
data,
})
return res.send(result)
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,43 @@
import prisma from '@/lib/prisma'
import { withSentry } from '@sentry/nextjs'
import { Answer } from 'db'
import { got } from 'got'
import { NextApiRequest, NextApiResponse } from 'next'
import { isNotDefined } from 'utils'
import { methodNotAllowed } from 'utils/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'PUT') {
const { uploadedFiles, ...answer } = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as Answer & { uploadedFiles?: boolean }
let storageUsed = 0
if (uploadedFiles && answer.content.includes('http')) {
const fileUrls = answer.content.split(', ')
const hasReachedStorageLimit = fileUrls[0] === null
if (!hasReachedStorageLimit) {
for (const url of fileUrls) {
const { headers } = await got(url)
const size = headers['content-length']
if (isNotDefined(size)) return
storageUsed += parseInt(size, 10)
}
}
}
const result = await prisma.answer.upsert({
where: {
resultId_blockId_groupId: {
resultId: answer.resultId,
groupId: answer.groupId,
blockId: answer.blockId,
},
},
create: { ...answer, storageUsed: storageUsed > 0 ? storageUsed : null },
update: { ...answer, storageUsed: storageUsed > 0 ? storageUsed : null },
})
return res.send(result)
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,41 @@
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { withSentry } from '@sentry/nextjs'
import { Group, WebhookBlock } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { byId, isWebhookBlock } from 'utils'
import { methodNotAllowed } from 'utils/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
const typebotId = req.query.typebotId as string
const typebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
select: { groups: true, webhooks: true },
})
const emptyWebhookBlocks = (typebot?.groups as Group[]).reduce<
{ blockId: string; name: string; url: string | undefined }[]
>((emptyWebhookBlocks, group) => {
const blocks = group.blocks.filter((block) =>
isWebhookBlock(block)
) as WebhookBlock[]
return [
...emptyWebhookBlocks,
...blocks.map((b) => ({
blockId: b.id,
name: `${group.title} > ${b.id}`,
url: typebot?.webhooks.find(byId(b.webhookId))?.url ?? undefined,
})),
]
}, [])
return res.send({ blocks: emptyWebhookBlocks })
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,45 @@
import { authenticateUser } from '@/features/auth/api'
import prisma from '@/lib/prisma'
import { withSentry } from '@sentry/nextjs'
import { Group, WebhookBlock } from 'models'
import { NextApiRequest, NextApiResponse } from 'next'
import { byId, isNotDefined, isWebhookBlock } from 'utils'
import { methodNotAllowed } from 'utils/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
const typebotId = req.query.typebotId as string
const typebot = await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
select: { groups: true, webhooks: true },
})
const emptyWebhookBlocks = (typebot?.groups as Group[]).reduce<
{ groupId: string; id: string; name: string }[]
>((emptyWebhookBlocks, group) => {
const blocks = group.blocks.filter(
(block) =>
isWebhookBlock(block) &&
isNotDefined(
typebot?.webhooks.find(byId((block as WebhookBlock).webhookId))?.url
)
)
return [
...emptyWebhookBlocks,
...blocks.map((s) => ({
id: s.id,
groupId: s.groupId,
name: `${group.title} > ${s.id}`,
})),
]
}, [])
return res.send({ steps: emptyWebhookBlocks })
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,17 @@
import { authenticateUser } from '@/features/auth/api'
import { withSentry } from '@sentry/nextjs'
import { NextApiRequest, NextApiResponse } from 'next'
import { isNotDefined } from 'utils'
import { methodNotAllowed } from 'utils/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') {
const user = await authenticateUser(req)
if (isNotDefined(user))
return res.status(404).send({ message: 'User not found' })
return res.send({ id: user.id, email: user.email })
}
return methodNotAllowed(res)
}
export default withSentry(handler)

View File

@ -0,0 +1,375 @@
{
"id": "qujHPjZ44xbrHb1hS1d8qC",
"createdAt": "2022-02-05T06:21:16.522Z",
"updatedAt": "2022-02-05T06:21:16.522Z",
"name": "My typebot",
"publishedTypebotId": null,
"folderId": null,
"groups": [
{
"id": "k6kY6gwRE6noPoYQNGzgUq",
"blocks": [
{
"id": "22HP69iipkLjJDTUcc1AWW",
"type": "start",
"label": "Start",
"groupId": "k6kY6gwRE6noPoYQNGzgUq",
"outgoingEdgeId": "oNvqaqNExdSH2kKEhKZHuE"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "kinRXxYop2X4d7F9qt8WNB",
"blocks": [
{
"id": "sc1y8VwDabNJgiVTBi4qtif",
"type": "text",
"groupId": "kinRXxYop2X4d7F9qt8WNB",
"content": {
"html": "<div>Welcome to <span class=\"slate-bold\">AA</span> (Awesome Agency)</div>",
"richText": [
{
"type": "p",
"children": [
{ "text": "Welcome to " },
{ "bold": true, "text": "AA" },
{ "text": " (Awesome Agency)" }
]
}
],
"plainText": "Welcome to AA (Awesome Agency)"
}
},
{
"id": "s7YqZTBeyCa4Hp3wN2j922c",
"type": "image",
"groupId": "kinRXxYop2X4d7F9qt8WNB",
"content": {
"url": "https://media2.giphy.com/media/XD9o33QG9BoMis7iM4/giphy.gif?cid=fe3852a3ihg8rvipzzky5lybmdyq38fhke2tkrnshwk52c7d&rid=giphy.gif&ct=g"
}
},
{
"id": "sbjZWLJGVkHAkDqS4JQeGow",
"type": "choice input",
"items": [
{
"id": "hQw2zbp7FDX7XYK9cFpbgC",
"type": 0,
"blockId": "sbjZWLJGVkHAkDqS4JQeGow",
"content": "Hi!"
}
],
"groupId": "kinRXxYop2X4d7F9qt8WNB",
"options": { "buttonLabel": "Send", "isMultipleChoice": false },
"outgoingEdgeId": "i51YhHpk1dtSyduFNf5Wim"
}
],
"title": "Welcome",
"graphCoordinates": { "x": 1, "y": 148 }
},
{
"id": "o4SH1UtKANnW5N5D67oZUz",
"blocks": [
{
"id": "sxeYubYN6XzhAfG7m9Fivhc",
"type": "text",
"groupId": "o4SH1UtKANnW5N5D67oZUz",
"content": {
"html": "<div>Great! Nice to meet you {{Name}}</div>",
"richText": [
{
"type": "p",
"children": [{ "text": "Great! Nice to meet you {{Name}}" }]
}
],
"plainText": "Great! Nice to meet you {{Name}}"
}
},
{
"id": "scQ5kduafAtfP9T8SHUJnGi",
"type": "text",
"groupId": "o4SH1UtKANnW5N5D67oZUz",
"content": {
"html": "<div>What&#x27;s the best email we can reach you at?</div>",
"richText": [
{
"type": "p",
"children": [
{ "text": "What's the best email we can reach you at?" }
]
}
],
"plainText": "What's the best email we can reach you at?"
}
},
{
"id": "snbsad18Bgry8yZ8DZCfdFD",
"type": "email input",
"groupId": "o4SH1UtKANnW5N5D67oZUz",
"options": {
"labels": { "button": "Send", "placeholder": "Type your email..." },
"variableId": "3VFChNVSCXQ2rXv4DrJ8Ah"
},
"outgoingEdgeId": "w3MiN1Ct38jT5NykVsgmb5"
}
],
"title": "Email",
"graphCoordinates": { "x": 669, "y": 141 }
},
{
"id": "q5dAhqSTCaNdiGSJm9B9Rw",
"blocks": [
{
"id": "sgtE2Sy7cKykac9B223Kq9R",
"type": "text",
"groupId": "q5dAhqSTCaNdiGSJm9B9Rw",
"content": {
"html": "<div>What&#x27;s your name?</div>",
"richText": [
{ "type": "p", "children": [{ "text": "What's your name?" }] }
],
"plainText": "What's your name?"
}
},
{
"id": "sqEsMo747LTDnY9FjQcEwUv",
"type": "text input",
"groupId": "q5dAhqSTCaNdiGSJm9B9Rw",
"options": {
"isLong": false,
"labels": {
"button": "Send",
"placeholder": "Type your answer..."
},
"variableId": "giiLFGw5xXBCHzvp1qAbdX"
},
"outgoingEdgeId": "4tYbERpi5Po4goVgt6rWXg"
}
],
"title": "Name",
"graphCoordinates": { "x": 340, "y": 143 }
},
{
"id": "fKqRz7iswk7ULaj5PJocZL",
"blocks": [
{
"id": "su7HceVXWyTCzi2vv3m4QbK",
"type": "text",
"groupId": "fKqRz7iswk7ULaj5PJocZL",
"content": {
"html": "<div>What services are you interested in?</div>",
"richText": [
{
"type": "p",
"children": [{ "text": "What services are you interested in?" }]
}
],
"plainText": "What services are you interested in?"
}
},
{
"id": "s5VQGsVF4hQgziQsXVdwPDW",
"type": "choice input",
"items": [
{
"id": "fnLCBF4NdraSwcubnBhk8H",
"type": 0,
"blockId": "s5VQGsVF4hQgziQsXVdwPDW",
"content": "Website dev"
},
{
"id": "a782h8ynMouY84QjH7XSnR",
"type": 0,
"blockId": "s5VQGsVF4hQgziQsXVdwPDW",
"content": "Content Marketing"
},
{
"id": "jGvh94zBByvVFpSS3w97zY",
"type": 0,
"blockId": "s5VQGsVF4hQgziQsXVdwPDW",
"content": "Social Media"
},
{
"id": "6PRLbKUezuFmwWtLVbvAQ7",
"type": 0,
"blockId": "s5VQGsVF4hQgziQsXVdwPDW",
"content": "UI / UX Design"
}
],
"groupId": "fKqRz7iswk7ULaj5PJocZL",
"options": { "buttonLabel": "Send", "isMultipleChoice": true },
"outgoingEdgeId": "ohTRakmcYJ7GdFWRZrWRjk"
}
],
"title": "Services",
"graphCoordinates": { "x": 1002, "y": 144 }
},
{
"id": "7qHBEyCMvKEJryBHzPmHjV",
"blocks": [
{
"id": "sqR8Sz9gW21aUYKtUikq7qZ",
"type": "text",
"groupId": "7qHBEyCMvKEJryBHzPmHjV",
"content": {
"html": "<div>Can you tell me a bit more about your needs?</div>",
"richText": [
{
"type": "p",
"children": [
{ "text": "Can you tell me a bit more about your needs?" }
]
}
],
"plainText": "Can you tell me a bit more about your needs?"
}
},
{
"id": "sqFy2G3C1mh9p6s3QBdSS5x",
"type": "text input",
"groupId": "7qHBEyCMvKEJryBHzPmHjV",
"options": {
"isLong": true,
"labels": { "button": "Send", "placeholder": "Type your answer..." }
},
"outgoingEdgeId": "sH5nUssG2XQbm6ZidGv9BY"
}
],
"title": "Additional information",
"graphCoordinates": { "x": 1337, "y": 145 }
},
{
"id": "vF7AD7zSAj7SNvN3gr9N94",
"blocks": [
{
"id": "seLegenCgUwMopRFeAefqZ7",
"type": "text",
"groupId": "vF7AD7zSAj7SNvN3gr9N94",
"content": {
"html": "<div>Perfect!</div>",
"richText": [{ "type": "p", "children": [{ "text": "Perfect!" }] }],
"plainText": "Perfect!"
}
},
{
"id": "s779Q1y51aVaDUJVrFb16vv",
"type": "text",
"groupId": "vF7AD7zSAj7SNvN3gr9N94",
"content": {
"html": "<div>We&#x27;ll get back to you at {{Email}}</div>",
"richText": [
{
"type": "p",
"children": [{ "text": "We'll get back to you at {{Email}}" }]
}
],
"plainText": "We'll get back to you at {{Email}}"
},
"outgoingEdgeId": "fTVo43AG97eKcaTrZf9KyV"
}
],
"title": "Bye",
"graphCoordinates": { "x": 1668, "y": 143 }
},
{
"id": "webhookGroup",
"graphCoordinates": { "x": 1996, "y": 134 },
"title": "Webhook",
"blocks": [
{
"id": "webhookBlock",
"groupId": "webhookGroup",
"type": "Webhook",
"options": { "responseVariableMapping": [], "variablesForTest": [] },
"webhookId": "webhook1"
}
]
}
],
"variables": [
{ "id": "giiLFGw5xXBCHzvp1qAbdX", "name": "Name" },
{ "id": "3VFChNVSCXQ2rXv4DrJ8Ah", "name": "Email" }
],
"edges": [
{
"id": "oNvqaqNExdSH2kKEhKZHuE",
"to": { "groupId": "kinRXxYop2X4d7F9qt8WNB" },
"from": {
"blockId": "22HP69iipkLjJDTUcc1AWW",
"groupId": "k6kY6gwRE6noPoYQNGzgUq"
}
},
{
"id": "i51YhHpk1dtSyduFNf5Wim",
"to": { "groupId": "q5dAhqSTCaNdiGSJm9B9Rw" },
"from": {
"blockId": "sbjZWLJGVkHAkDqS4JQeGow",
"groupId": "kinRXxYop2X4d7F9qt8WNB"
}
},
{
"id": "4tYbERpi5Po4goVgt6rWXg",
"to": { "groupId": "o4SH1UtKANnW5N5D67oZUz" },
"from": {
"blockId": "sqEsMo747LTDnY9FjQcEwUv",
"groupId": "q5dAhqSTCaNdiGSJm9B9Rw"
}
},
{
"id": "w3MiN1Ct38jT5NykVsgmb5",
"to": { "groupId": "fKqRz7iswk7ULaj5PJocZL" },
"from": {
"blockId": "snbsad18Bgry8yZ8DZCfdFD",
"groupId": "o4SH1UtKANnW5N5D67oZUz"
}
},
{
"id": "ohTRakmcYJ7GdFWRZrWRjk",
"to": { "groupId": "7qHBEyCMvKEJryBHzPmHjV" },
"from": {
"blockId": "s5VQGsVF4hQgziQsXVdwPDW",
"groupId": "fKqRz7iswk7ULaj5PJocZL"
}
},
{
"id": "sH5nUssG2XQbm6ZidGv9BY",
"to": { "groupId": "vF7AD7zSAj7SNvN3gr9N94" },
"from": {
"blockId": "sqFy2G3C1mh9p6s3QBdSS5x",
"groupId": "7qHBEyCMvKEJryBHzPmHjV"
}
},
{
"from": {
"groupId": "vF7AD7zSAj7SNvN3gr9N94",
"blockId": "s779Q1y51aVaDUJVrFb16vv"
},
"to": { "groupId": "webhookGroup" },
"id": "fTVo43AG97eKcaTrZf9KyV"
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": { "isBrandingEnabled": true },
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null
}

View File

@ -0,0 +1,131 @@
{
"id": "cl45ojo7z01383q1av699t0qj",
"createdAt": "2022-06-08T14:22:14.879Z",
"updatedAt": "2022-06-08T16:19:32.893Z",
"icon": null,
"name": "My typebot",
"publishedTypebotId": "cl45ol3j8000f2e6gcifqf21t",
"folderId": null,
"groups": [
{
"id": "cl45ojo7y00013q1aaysi2o6i",
"blocks": [
{
"id": "cl45ojo7y00023q1aavrwd411",
"type": "start",
"label": "Start",
"groupId": "cl45ojo7y00013q1aaysi2o6i",
"outgoingEdgeId": "cl45ojxvc00082e6gw1xqnxpp"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "cl45ojrrd00062e6g17tuu9t0",
"blocks": [
{
"id": "cl45ojrre00072e6gk91592pj",
"type": "text",
"groupId": "cl45ojrrd00062e6g17tuu9t0",
"content": {
"html": "<div>Hey there, upload please</div>",
"richText": [
{
"type": "p",
"children": [{ "text": "Hey there, upload please" }]
}
],
"plainText": "Hey there, upload please"
}
},
{
"id": "cl45ojzs300092e6gkno525c4",
"type": "file input",
"groupId": "cl45ojrrd00062e6g17tuu9t0",
"options": {
"labels": {
"button": "Upload",
"placeholder": "<strong>\n Click to upload\n </strong> or drag and drop<br>\n (size limit: 10MB)"
},
"variableId": "vcl45ok77i000a2e6g79ye53a2",
"isMultipleAllowed": true
},
"outgoingEdgeId": "cl45okfgz000d2e6g7z3wnqgq"
}
],
"title": "Group #1",
"graphCoordinates": { "x": 416, "y": 98 }
},
{
"id": "cl45ok963000b2e6g2ky0wkvx",
"blocks": [
{
"id": "cl45ok963000c2e6g9snvbhw4",
"type": "text",
"groupId": "cl45ok963000b2e6g2ky0wkvx",
"content": {
"html": "<div>Thank you!</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Thank you!" }] }
],
"plainText": "Thank you!"
}
}
],
"title": "Group #2",
"graphCoordinates": { "x": 863, "y": 249 }
}
],
"variables": [{ "id": "vcl45ok77i000a2e6g79ye53a2", "name": "Files" }],
"edges": [
{
"id": "cl45ojxvc00082e6gw1xqnxpp",
"to": { "groupId": "cl45ojrrd00062e6g17tuu9t0" },
"from": {
"blockId": "cl45ojo7y00023q1aavrwd411",
"groupId": "cl45ojo7y00013q1aaysi2o6i"
}
},
{
"id": "cl45okfgz000d2e6g7z3wnqgq",
"to": { "groupId": "cl45ok963000b2e6g2ky0wkvx" },
"from": {
"blockId": "cl45ojzs300092e6gkno525c4",
"groupId": "cl45ojrrd00062e6g17tuu9t0"
}
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostAvatar": {
"url": "https://avatars.githubusercontent.com/u/16015833?v=4",
"isEnabled": true
},
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": true,
"isInputPrefillEnabled": true,
"isHideQueryParamsEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": "my-typebot-699t0qj",
"customDomain": null,
"workspaceId": "proWorkspace"
}

View File

@ -0,0 +1,154 @@
{
"id": "cl0jrltqx0037601agzjiy7t4",
"createdAt": "2022-03-09T16:17:51.321Z",
"updatedAt": "2022-03-09T16:19:07.037Z",
"name": "My typebot",
"publishedTypebotId": "dm12bh6hmEQemywn86osJD",
"folderId": null,
"groups": [
{
"id": "dqork4dJJZk3RgKYavBpRE",
"blocks": [
{
"id": "u7Px8eD9MWXNJEBwxQwJCF",
"type": "start",
"label": "Start",
"groupId": "dqork4dJJZk3RgKYavBpRE",
"outgoingEdgeId": "b3XsreaqtWt4CrZZmCKDpa"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "2Vrpgk5VP9BUo3vKtM5kws",
"blocks": [
{
"id": "s6GezsfD612D1naKwvhDFgA",
"type": "text",
"groupId": "2Vrpgk5VP9BUo3vKtM5kws",
"content": {
"html": "<div>Hi what&#x27;s your name?</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Hi what's your name?" }] }
],
"plainText": "Hi what's your name?"
}
},
{
"id": "sq3mPXUrugs5t6FoME3T4t4",
"type": "text input",
"groupId": "2Vrpgk5VP9BUo3vKtM5kws",
"options": {
"isLong": false,
"labels": {
"button": "Send",
"placeholder": "Type your answer..."
},
"variableId": "93BD32WVM5JEQ1nmWtr2S5"
}
},
{
"id": "s57hbzfpG2sVvXefznVhbVB",
"type": "text",
"groupId": "2Vrpgk5VP9BUo3vKtM5kws",
"content": {
"html": "<div>How old are you?</div>",
"richText": [
{ "type": "p", "children": [{ "text": "How old are you?" }] }
],
"plainText": "How old are you?"
}
},
{
"id": "s4fFn3s7nouQk88iJ3oLgx6",
"type": "number input",
"groupId": "2Vrpgk5VP9BUo3vKtM5kws",
"options": {
"labels": { "button": "Send", "placeholder": "Type a number..." }
}
},
{
"id": "scR6MewJwkPNJzABYG8NEA4",
"type": "text",
"groupId": "2Vrpgk5VP9BUo3vKtM5kws",
"content": {
"html": "<div>Cool!</div>",
"richText": [{ "type": "p", "children": [{ "text": "Cool!" }] }],
"plainText": "Cool!"
}
},
{
"id": "s5fo1s8UTyHQ7CfqC6MRxyW",
"type": "text",
"groupId": "2Vrpgk5VP9BUo3vKtM5kws",
"content": {
"html": "<div>Do you eat pizza?</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Do you eat pizza?" }] }
],
"plainText": "Do you eat pizza?"
}
},
{
"id": "sxn8UjQ2MjEMuRjhkh7LWws",
"type": "choice input",
"items": [
{
"id": "wGKXMr4mfySw1HNThND2Xd",
"type": 0,
"blockId": "sxn8UjQ2MjEMuRjhkh7LWws",
"content": "Yes"
},
{
"id": "qzqzVqMeo6TDUdMYckLZmf",
"type": 0,
"blockId": "sxn8UjQ2MjEMuRjhkh7LWws",
"content": "No"
}
],
"groupId": "2Vrpgk5VP9BUo3vKtM5kws",
"options": { "buttonLabel": "Send", "isMultipleChoice": false }
}
],
"title": "Group #1",
"graphCoordinates": { "x": 386, "y": 108 }
}
],
"variables": [{ "id": "93BD32WVM5JEQ1nmWtr2S5", "name": "Name" }],
"edges": [
{
"id": "b3XsreaqtWt4CrZZmCKDpa",
"to": { "groupId": "2Vrpgk5VP9BUo3vKtM5kws" },
"from": {
"blockId": "u7Px8eD9MWXNJEBwxQwJCF",
"groupId": "dqork4dJJZk3RgKYavBpRE"
}
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": "my-typebot-zjiy7t4",
"customDomain": null
}

View File

@ -0,0 +1,73 @@
{
"id": "cl0ibhi7s0018n21aarlmg0cm",
"createdAt": "2022-03-08T15:58:49.720Z",
"updatedAt": "2022-03-08T16:07:18.899Z",
"name": "My typebot",
"publishedTypebotId": null,
"folderId": null,
"groups": [
{
"id": "1qQrnsLzRim1LqCrhbj1MW",
"blocks": [
{
"id": "8srsGhdBJK8v88Xo1RRS4C",
"type": "start",
"label": "Start",
"groupId": "1qQrnsLzRim1LqCrhbj1MW",
"outgoingEdgeId": "ovUHhwr6THMhqtn8QbkjtA"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "wSR4VCcDNDTTsD9Szi2xH8",
"blocks": [
{
"id": "sw6nHJfkMsM4pxZxMBB6QqW",
"type": "Typebot link",
"groupId": "wSR4VCcDNDTTsD9Szi2xH8",
"options": { "typebotId": "cl0ibhv8d0130n21aw8doxhj5" }
}
],
"title": "Group #1",
"graphCoordinates": { "x": 363, "y": 199 }
}
],
"variables": [],
"edges": [
{
"id": "ovUHhwr6THMhqtn8QbkjtA",
"to": { "groupId": "wSR4VCcDNDTTsD9Szi2xH8" },
"from": {
"blockId": "8srsGhdBJK8v88Xo1RRS4C",
"groupId": "1qQrnsLzRim1LqCrhbj1MW"
}
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null
}

View File

@ -0,0 +1,76 @@
{
"id": "cl0ibhv8d0130n21aw8doxhj5",
"createdAt": "2022-03-08T15:59:06.589Z",
"updatedAt": "2022-03-08T15:59:10.498Z",
"name": "Another typebot",
"publishedTypebotId": null,
"folderId": null,
"groups": [
{
"id": "p4ByLVoKiDRyRoPHKmcTfw",
"blocks": [
{
"id": "rw6smEWEJzHKbiVKLUKFvZ",
"type": "start",
"label": "Start",
"groupId": "p4ByLVoKiDRyRoPHKmcTfw",
"outgoingEdgeId": "1z3pfiatTUHbraD2uSoA3E"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "bg4QEJseUsTP496H27j5k2",
"graphCoordinates": { "x": 366, "y": 191 },
"title": "Group #1",
"blocks": [
{
"id": "s8ZeBL9p5za77eBmdKECLYq",
"groupId": "bg4QEJseUsTP496H27j5k2",
"type": "text input",
"options": {
"isLong": false,
"labels": { "button": "Send", "placeholder": "Type your answer..." }
}
}
]
}
],
"variables": [],
"edges": [
{
"from": {
"groupId": "p4ByLVoKiDRyRoPHKmcTfw",
"blockId": "rw6smEWEJzHKbiVKLUKFvZ"
},
"to": { "groupId": "bg4QEJseUsTP496H27j5k2" },
"id": "1z3pfiatTUHbraD2uSoA3E"
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null
}

View File

@ -0,0 +1,121 @@
{
"id": "cl13bgvlm0050t71aq6a3w777",
"createdAt": "2022-03-23T08:41:30.106Z",
"updatedAt": "2022-03-23T08:41:30.106Z",
"name": "My typebot",
"publishedTypebotId": null,
"folderId": null,
"groups": [
{
"id": "cl13bgvlk0000t71a4wabccvw",
"blocks": [
{
"id": "cl13bgvlk0001t71a3pilbj53",
"type": "start",
"label": "Start",
"groupId": "cl13bgvlk0000t71a4wabccvw",
"outgoingEdgeId": "cl13bgz4800062e6dv7ejcchb"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "cl13bgy1s00042e6dao1wyobm",
"graphCoordinates": { "x": 329, "y": 65 },
"title": "Group #1",
"blocks": [
{
"id": "cl13bgy1w00052e6d5x57wt7o",
"groupId": "cl13bgy1s00042e6dao1wyobm",
"type": "text",
"content": {
"html": "<div>Hey I know you!</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Hey I know you!" }] }
],
"plainText": "Hey I know you!"
}
},
{
"id": "cl13bh6jd00072e6dftdirwy4",
"groupId": "cl13bgy1s00042e6dao1wyobm",
"type": "text",
"content": {
"html": "<div>Your name is {{Name}}</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Your name is {{Name}}" }] }
],
"plainText": "Your name is {{Name}}"
}
},
{
"id": "cl13bhfxd00092e6dydvcqlhm",
"groupId": "cl13bgy1s00042e6dao1wyobm",
"type": "text",
"content": {
"html": "<div>What&#x27;s your email?</div>",
"richText": [
{ "type": "p", "children": [{ "text": "What's your email?" }] }
],
"plainText": "What's your email?"
}
},
{
"id": "cl13bhnay000a2e6dxa630dh3",
"groupId": "cl13bgy1s00042e6dao1wyobm",
"type": "email input",
"options": {
"labels": { "button": "Send", "placeholder": "Type your email..." },
"retryMessageContent": "This email doesn't seem to be valid. Can you type it again?",
"variableId": "cl13bhr3w000b2e6d3c9kid0p"
}
}
]
}
],
"variables": [
{ "id": "cl13bha3l00082e6duaz0xm6f", "name": "Name" },
{ "id": "cl13bhr3w000b2e6d3c9kid0p", "name": "Email" }
],
"edges": [
{
"from": {
"groupId": "cl13bgvlk0000t71a4wabccvw",
"blockId": "cl13bgvlk0001t71a3pilbj53"
},
"to": { "groupId": "cl13bgy1s00042e6dao1wyobm" },
"id": "cl13bgz4800062e6dv7ejcchb"
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostAvatar": {
"url": "https://avatars.githubusercontent.com/u/16015833?v=4",
"isEnabled": true
},
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": true,
"isInputPrefillEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null
}

View File

@ -0,0 +1,121 @@
{
"id": "cl1rxxg6l334509lhv44f8qnx",
"createdAt": "2022-04-09T14:16:43.053Z",
"updatedAt": "2022-04-12T14:34:44.287Z",
"icon": null,
"name": "My typebot",
"publishedTypebotId": null,
"folderId": null,
"groups": [
{
"id": "cl1rxxg6k000009lhd0mgfy5i",
"blocks": [
{
"id": "cl1rxxg6k000109lh2is0gfua",
"type": "start",
"label": "Start",
"groupId": "cl1rxxg6k000009lhd0mgfy5i",
"outgoingEdgeId": "cl1w8rhzs000f2e694836a1k3"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "cl1w8repd000b2e69fwiqsd00",
"graphCoordinates": { "x": 364, "y": -2 },
"title": "Group #1",
"blocks": [
{
"id": "cl1w8repg000c2e699jqwrepg",
"groupId": "cl1w8repd000b2e69fwiqsd00",
"type": "choice input",
"options": { "buttonLabel": "Send", "isMultipleChoice": false },
"items": [
{
"id": "cl1w8repg000d2e69d8xnkqeq",
"blockId": "cl1w8repg000c2e699jqwrepg",
"type": 0,
"content": "Send email",
"outgoingEdgeId": "cl1w8rkoo000i2e69hs60pk0q"
}
]
}
]
},
{
"id": "cl1w8rjaf000g2e69cqd2bwvk",
"graphCoordinates": { "x": 715, "y": -10 },
"title": "Group #2",
"blocks": [
{
"id": "cl1w8rjai000h2e695uvoimq7",
"groupId": "cl1w8rjaf000g2e69cqd2bwvk",
"type": "Email",
"options": {
"credentialsId": "send-email-credentials",
"recipients": ["baptiste.arnaud95@gmail.com"],
"replyTo": "contact@baptiste-arnaud.fr",
"cc": ["test1@gmail.com", "test2@gmail.com"],
"bcc": ["test3@gmail.com", "test4@gmail.com"],
"subject": "Hey!",
"body": "Test email"
}
}
]
}
],
"variables": [
{ "id": "vcl1rxxovn000z2e69y6t9dxge", "name": "Score" },
{ "id": "vcl1rylq5v00042e69tdml6hi3", "name": "Age" }
],
"edges": [
{
"from": {
"groupId": "cl1rxxg6k000009lhd0mgfy5i",
"blockId": "cl1rxxg6k000109lh2is0gfua"
},
"to": { "groupId": "cl1w8repd000b2e69fwiqsd00" },
"id": "cl1w8rhzs000f2e694836a1k3"
},
{
"from": {
"groupId": "cl1w8repd000b2e69fwiqsd00",
"blockId": "cl1w8repg000c2e699jqwrepg",
"itemId": "cl1w8repg000d2e69d8xnkqeq"
},
"to": { "groupId": "cl1w8rjaf000g2e69cqd2bwvk" },
"id": "cl1w8rkoo000i2e69hs60pk0q"
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostAvatar": {
"url": "https://avatars.githubusercontent.com/u/16015833?v=4",
"isEnabled": true
},
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": true,
"isInputPrefillEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null
}

View File

@ -0,0 +1,279 @@
{
"id": "cl9ip9u0l00001ad79a2lzm55",
"createdAt": "2022-10-21T16:22:07.414Z",
"updatedAt": "2022-10-21T16:30:57.642Z",
"icon": null,
"name": "My typebot",
"publishedTypebotId": null,
"folderId": null,
"groups": [
{
"id": "cl9ip9u0j0000d71a5d98gwni",
"title": "Start",
"blocks": [
{
"id": "cl9ip9u0j0001d71a44dsd2p1",
"type": "start",
"label": "Start",
"groupId": "cl9ip9u0j0000d71a5d98gwni",
"outgoingEdgeId": "cl9ipkkb2001b3b6oh3vptq9k"
}
],
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "cl9ipa38j00083b6o69e90m4t",
"graphCoordinates": { "x": 340, "y": 341 },
"title": "Group #1",
"blocks": [
{
"id": "cl9ipaaut000a3b6ovrqlec3x",
"groupId": "cl9ipa38j00083b6o69e90m4t",
"type": "text input",
"options": {
"isLong": false,
"labels": { "button": "Send", "placeholder": "Type a name..." },
"variableId": "vcl9ipajth000c3b6okl97r81j"
}
},
{
"id": "cl9ipan8f000d3b6oo2ovi3ac",
"groupId": "cl9ipa38j00083b6o69e90m4t",
"type": "number input",
"options": {
"labels": { "button": "Send", "placeholder": "Type an age..." },
"variableId": "vcl9ipaszl000e3b6ousjxuw7b"
}
},
{
"id": "cl9ipb08n000f3b6ok3mi2p48",
"groupId": "cl9ipa38j00083b6o69e90m4t",
"type": "choice input",
"options": {
"buttonLabel": "Send",
"isMultipleChoice": false,
"variableId": "vcl9ipg4tb00103b6oue08w3nm"
},
"items": [
{
"id": "cl9ipb08n000g3b6okr691uad",
"blockId": "cl9ipb08n000f3b6ok3mi2p48",
"type": 0,
"content": "Male"
},
{
"blockId": "cl9ipb08n000f3b6ok3mi2p48",
"type": 0,
"id": "cl9ipb2kk000h3b6oadwtonnz",
"content": "Female"
}
],
"outgoingEdgeId": "cl9ipcp83000o3b6odsn0a9a1"
}
]
},
{
"id": "cl9ipbcjy000j3b6oqngo7luv",
"graphCoordinates": { "x": 781, "y": 91 },
"title": "Group #2",
"blocks": [
{
"id": "cl9ipbl6l000m3b6o3evn41kv",
"groupId": "cl9ipbcjy000j3b6oqngo7luv",
"type": "Set variable",
"options": {
"variableId": "vcl9ipbokm000n3b6o06hvarrf",
"expressionToEvaluate": "{\n \"name\": \"John\",\n \"age\": 25,\n \"gender\": \"male\"\n}"
}
},
{
"id": "cl9ipbcjy000k3b6oe8lta5c1",
"groupId": "cl9ipbcjy000j3b6oqngo7luv",
"type": "Webhook",
"options": {
"responseVariableMapping": [
{
"id": "cl9ipdspg000p3b6ognbfvmdx",
"variableId": "vcl9ipdxnj000q3b6oy55th4xb",
"bodyPath": "data"
}
],
"variablesForTest": [],
"isAdvancedConfig": true,
"isCustomBody": true
},
"webhookId": "full-body-webhook"
},
{
"id": "cl9ipe5t8000s3b6ocswre500",
"groupId": "cl9ipbcjy000j3b6oqngo7luv",
"type": "text",
"content": {
"html": "<div>Data of first request:</div><div></div><div>{{Data}}</div>",
"richText": [
{
"type": "p",
"children": [{ "text": "Data of first request:" }]
},
{ "type": "p", "children": [{ "text": "" }] },
{ "type": "p", "children": [{ "text": "{{Data}}" }] }
],
"plainText": "Data of first request:{{Data}}"
},
"outgoingEdgeId": "cl9ipet83000z3b6of6zfqota"
}
]
},
{
"id": "cl9ipej6b000u3b6oeaz305l6",
"graphCoordinates": { "x": 1138, "y": 85 },
"title": "Group #2 copy",
"blocks": [
{
"id": "cl9ipej6c000w3b6otzk247vl",
"groupId": "cl9ipej6b000u3b6oeaz305l6",
"type": "Webhook",
"options": {
"responseVariableMapping": [
{
"id": "cl9ipdspg000p3b6ognbfvmdx",
"variableId": "vcl9ipdxnj000q3b6oy55th4xb",
"bodyPath": "data"
}
],
"variablesForTest": [],
"isAdvancedConfig": true,
"isCustomBody": true
},
"webhookId": "partial-body-webhook"
},
{
"id": "cl9ipej6c000y3b6oegzkgloq",
"groupId": "cl9ipej6b000u3b6oeaz305l6",
"type": "text",
"content": {
"html": "<div>Data of second request:</div><div></div><div>{{Data}}</div>",
"richText": [
{
"type": "p",
"children": [{ "text": "Data of second request:" }]
},
{ "type": "p", "children": [{ "text": "" }] },
{ "type": "p", "children": [{ "text": "{{Data}}" }] }
],
"plainText": "Data of second request:{{Data}}"
}
}
]
},
{
"id": "cl9ipkaer00153b6ov230yuv2",
"graphCoordinates": { "x": 333, "y": 26 },
"title": "Group #4",
"blocks": [
{
"id": "cl9ipkaer00163b6o0ohmmscn",
"groupId": "cl9ipkaer00153b6ov230yuv2",
"type": "choice input",
"options": { "buttonLabel": "Send", "isMultipleChoice": false },
"items": [
{
"id": "cl9ipkaer00173b6oxof4zrqn",
"blockId": "cl9ipkaer00163b6o0ohmmscn",
"type": 0,
"content": "Send failing webhook"
}
]
},
{
"id": "cl9ipki9u00193b6okmhudo0f",
"groupId": "cl9ipkaer00153b6ov230yuv2",
"type": "Webhook",
"options": {
"responseVariableMapping": [],
"variablesForTest": [],
"isAdvancedConfig": false,
"isCustomBody": false
},
"webhookId": "failing-webhook",
"outgoingEdgeId": "cl9ipklm0001c3b6oy0a5nbhr"
}
]
}
],
"variables": [
{ "id": "vcl9ipajth000c3b6okl97r81j", "name": "Name" },
{ "id": "vcl9ipaszl000e3b6ousjxuw7b", "name": "Age" },
{ "id": "vcl9ipbokm000n3b6o06hvarrf", "name": "Full body" },
{ "id": "vcl9ipdxnj000q3b6oy55th4xb", "name": "Data" },
{ "id": "vcl9ipg4tb00103b6oue08w3nm", "name": "Gender" }
],
"edges": [
{
"from": {
"groupId": "cl9ipa38j00083b6o69e90m4t",
"blockId": "cl9ipb08n000f3b6ok3mi2p48"
},
"to": { "groupId": "cl9ipbcjy000j3b6oqngo7luv" },
"id": "cl9ipcp83000o3b6odsn0a9a1"
},
{
"from": {
"groupId": "cl9ipbcjy000j3b6oqngo7luv",
"blockId": "cl9ipe5t8000s3b6ocswre500"
},
"to": { "groupId": "cl9ipej6b000u3b6oeaz305l6" },
"id": "cl9ipet83000z3b6of6zfqota"
},
{
"from": {
"groupId": "cl9ip9u0j0000d71a5d98gwni",
"blockId": "cl9ip9u0j0001d71a44dsd2p1"
},
"to": { "groupId": "cl9ipkaer00153b6ov230yuv2" },
"id": "cl9ipkkb2001b3b6oh3vptq9k"
},
{
"from": {
"groupId": "cl9ipkaer00153b6ov230yuv2",
"blockId": "cl9ipki9u00193b6okmhudo0f"
},
"to": { "groupId": "cl9ipa38j00083b6o69e90m4t" },
"id": "cl9ipklm0001c3b6oy0a5nbhr"
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostAvatar": {
"url": "https://avatars.githubusercontent.com/u/16015833?v=4",
"isEnabled": true
},
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": false,
"isInputPrefillEnabled": true,
"isHideQueryParamsEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null,
"workspaceId": "proWorkspace",
"isArchived": false,
"isClosed": false
}

View File

@ -0,0 +1,23 @@
import { CredentialsType, SmtpCredentialsData } from 'models'
import { PrismaClient } from 'db'
import { encrypt } from 'utils/api'
import { proWorkspaceId } from 'utils/playwright/databaseSetup'
const prisma = new PrismaClient()
export const createSmtpCredentials = (
id: string,
smtpData: SmtpCredentialsData
) => {
const { encryptedData, iv } = encrypt(smtpData)
return prisma.credentials.create({
data: {
id,
data: encryptedData,
iv,
name: smtpData.from.email as string,
type: CredentialsType.SMTP,
workspaceId: proWorkspaceId,
},
})
}

View File

@ -0,0 +1,4 @@
import path from 'path'
export const getTestAsset = (name: string) =>
path.join(__dirname, '..', 'assets', name)

View File

@ -0,0 +1,53 @@
import { CollaborationType, Prisma, User, WorkspaceRole } from 'db'
import { env } from 'utils'
const parseWhereFilter = (
typebotIds: string[] | string,
user: User,
type: 'read' | 'write'
): Prisma.TypebotWhereInput => ({
OR: [
{
id: typeof typebotIds === 'string' ? typebotIds : { in: typebotIds },
collaborators: {
some: {
userId: user.id,
type: type === 'write' ? CollaborationType.WRITE : undefined,
},
},
},
{
id: typeof typebotIds === 'string' ? typebotIds : { in: typebotIds },
workspace:
(type === 'read' && user.email === process.env.ADMIN_EMAIL) ||
env('E2E_TEST') === 'true'
? undefined
: {
members: {
some: { userId: user.id, role: { not: WorkspaceRole.GUEST } },
},
},
},
],
})
export const canReadTypebot = (typebotId: string, user: User) =>
parseWhereFilter(typebotId, user, 'read')
export const canWriteTypebot = (typebotId: string, user: User) =>
parseWhereFilter(typebotId, user, 'write')
export const canReadTypebots = (typebotIds: string[], user: User) =>
parseWhereFilter(typebotIds, user, 'read')
export const canWriteTypebots = (typebotIds: string[], user: User) =>
parseWhereFilter(typebotIds, user, 'write')
export const canEditGuests = (user: User, typebotId: string) => ({
id: typebotId,
workspace: {
members: {
some: { userId: user.id, role: { not: WorkspaceRole.GUEST } },
},
},
})