♻️ (viewer) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
643571fe7d
commit
a9d04798bc
6
apps/viewer/src/assets/styles.css
Normal file
6
apps/viewer/src/assets/styles.css
Normal 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';
|
||||
}
|
33
apps/viewer/src/components/ErrorPage.tsx
Normal file
33
apps/viewer/src/components/ErrorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
14
apps/viewer/src/components/NotFoundPage.tsx
Normal file
14
apps/viewer/src/components/NotFoundPage.tsx
Normal 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're looking for doesn't exist</h2>
|
||||
</div>
|
||||
)
|
65
apps/viewer/src/components/Seo.tsx
Normal file
65
apps/viewer/src/components/Seo.tsx
Normal 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>
|
||||
)
|
159
apps/viewer/src/components/TypebotPage.tsx
Normal file
159
apps/viewer/src/components/TypebotPage.tsx
Normal 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 {}
|
||||
}
|
1
apps/viewer/src/features/answers/index.ts
Normal file
1
apps/viewer/src/features/answers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { upsertAnswerQuery } from './queries/upsertAnswerQuery'
|
@ -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,
|
||||
})
|
19
apps/viewer/src/features/auth/api/authenticateUser.ts
Normal file
19
apps/viewer/src/features/auth/api/authenticateUser.ts
Normal 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)
|
1
apps/viewer/src/features/auth/api/index.ts
Normal file
1
apps/viewer/src/features/auth/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { authenticateUser } from './authenticateUser'
|
31
apps/viewer/src/features/chatwoot/chatwoot.spec.ts
Normal file
31
apps/viewer/src/features/chatwoot/chatwoot.spec.ts
Normal 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()
|
||||
})
|
119
apps/viewer/src/features/fileUpload/fileUpload.spec.ts
Normal file
119
apps/viewer/src/features/fileUpload/fileUpload.spec.ts
Normal 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()
|
||||
// })
|
||||
// })
|
2
apps/viewer/src/features/logs/api/index.ts
Normal file
2
apps/viewer/src/features/logs/api/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './saveErrorLog'
|
||||
export * from './saveSuccessLog'
|
7
apps/viewer/src/features/logs/api/saveErrorLog.ts
Normal file
7
apps/viewer/src/features/logs/api/saveErrorLog.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { saveLog } from './utils'
|
||||
|
||||
export const saveErrorLog = (
|
||||
resultId: string | undefined,
|
||||
message: string,
|
||||
details?: unknown
|
||||
) => saveLog('error', resultId, message, details)
|
7
apps/viewer/src/features/logs/api/saveSuccessLog.ts
Normal file
7
apps/viewer/src/features/logs/api/saveSuccessLog.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { saveLog } from './utils'
|
||||
|
||||
export const saveSuccessLog = (
|
||||
resultId: string | undefined,
|
||||
message: string,
|
||||
details?: unknown
|
||||
) => saveLog('success', resultId, message, details)
|
26
apps/viewer/src/features/logs/api/utils.ts
Normal file
26
apps/viewer/src/features/logs/api/utils.ts
Normal 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
|
||||
}
|
||||
}
|
1
apps/viewer/src/features/results/index.ts
Normal file
1
apps/viewer/src/features/results/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { createResultQuery, updateResultQuery } from './queries'
|
@ -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',
|
||||
})
|
||||
}
|
2
apps/viewer/src/features/results/queries/index.ts
Normal file
2
apps/viewer/src/features/results/queries/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './createResultQuery'
|
||||
export * from './updateResultQuery'
|
@ -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,
|
||||
})
|
51
apps/viewer/src/features/results/results.spec.ts
Normal file
51
apps/viewer/src/features/results/results.spec.ts
Normal 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)
|
||||
})
|
56
apps/viewer/src/features/sendEmail/sendEmail.spec.ts
Normal file
56
apps/viewer/src/features/sendEmail/sendEmail.spec.ts
Normal 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()
|
||||
})
|
177
apps/viewer/src/features/settings/settings.spec.ts
Normal file
177
apps/viewer/src/features/settings/settings.spec.ts
Normal 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()
|
||||
})
|
@ -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
|
||||
}
|
1
apps/viewer/src/features/typebotLink/api/index.ts
Normal file
1
apps/viewer/src/features/typebotLink/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './getLinkedTypebots'
|
38
apps/viewer/src/features/typebotLink/typebotLink.spec.ts
Normal file
38
apps/viewer/src/features/typebotLink/typebotLink.spec.ts
Normal 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()
|
||||
})
|
19
apps/viewer/src/features/usage/usage.spec.ts
Normal file
19
apps/viewer/src/features/usage/usage.spec.ts
Normal 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()
|
||||
// })
|
22
apps/viewer/src/features/variables/variables.spec.ts
Normal file
22
apps/viewer/src/features/variables/variables.spec.ts
Normal 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()
|
||||
})
|
1
apps/viewer/src/features/webhook/api/index.ts
Normal file
1
apps/viewer/src/features/webhook/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './parseSampleResult'
|
192
apps/viewer/src/features/webhook/api/parseSampleResult.ts
Normal file
192
apps/viewer/src/features/webhook/api/parseSampleResult.ts
Normal 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
|
||||
}
|
198
apps/viewer/src/features/webhook/webhook.spec.ts
Normal file
198
apps/viewer/src/features/webhook/webhook.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
48
apps/viewer/src/lib/google-sheets.ts
Normal file
48
apps/viewer/src/lib/google-sheets.ts
Normal 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 },
|
||||
})
|
||||
}
|
15
apps/viewer/src/lib/prisma.ts
Normal file
15
apps/viewer/src/lib/prisma.ts
Normal 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
|
120
apps/viewer/src/pages/[[...publicId]].tsx
Normal file
120
apps/viewer/src/pages/[[...publicId]].tsx
Normal 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
|
15
apps/viewer/src/pages/_app.tsx
Normal file
15
apps/viewer/src/pages/_app.tsx
Normal 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} />
|
||||
}
|
16
apps/viewer/src/pages/_document.tsx
Normal file
16
apps/viewer/src/pages/_document.tsx
Normal 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
|
76
apps/viewer/src/pages/_error.tsx
Normal file
76
apps/viewer/src/pages/_error.tsx
Normal 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
|
@ -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)
|
@ -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)
|
15
apps/viewer/src/pages/api/mock/fail.ts
Normal file
15
apps/viewer/src/pages/api/mock/fail.ts
Normal 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)
|
15
apps/viewer/src/pages/api/mock/success.ts
Normal file
15
apps/viewer/src/pages/api/mock/success.ts
Normal 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)
|
22
apps/viewer/src/pages/api/publicTypebots/[typebotId].ts
Normal file
22
apps/viewer/src/pages/api/publicTypebots/[typebotId].ts
Normal 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)
|
20
apps/viewer/src/pages/api/typebots.ts
Normal file
20
apps/viewer/src/pages/api/typebots.ts
Normal 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)
|
@ -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)
|
@ -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
|
@ -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)
|
@ -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
|
@ -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)
|
@ -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)
|
@ -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)
|
@ -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)
|
@ -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)
|
@ -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)
|
168
apps/viewer/src/pages/api/typebots/[typebotId]/results.ts
Normal file
168
apps/viewer/src/pages/api/typebots/[typebotId]/results.ts
Normal 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
|
@ -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)
|
@ -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)
|
@ -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)
|
@ -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)
|
17
apps/viewer/src/pages/api/users/me.ts
Normal file
17
apps/viewer/src/pages/api/users/me.ts
Normal 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)
|
375
apps/viewer/src/test/assets/typebots/api.json
Normal file
375
apps/viewer/src/test/assets/typebots/api.json
Normal 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'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'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'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
|
||||
}
|
131
apps/viewer/src/test/assets/typebots/fileUpload.json
Normal file
131
apps/viewer/src/test/assets/typebots/fileUpload.json
Normal 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"
|
||||
}
|
154
apps/viewer/src/test/assets/typebots/hugeGroup.json
Normal file
154
apps/viewer/src/test/assets/typebots/hugeGroup.json
Normal 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'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
|
||||
}
|
73
apps/viewer/src/test/assets/typebots/linkTypebots/1.json
Normal file
73
apps/viewer/src/test/assets/typebots/linkTypebots/1.json
Normal 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
|
||||
}
|
76
apps/viewer/src/test/assets/typebots/linkTypebots/2.json
Normal file
76
apps/viewer/src/test/assets/typebots/linkTypebots/2.json
Normal 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
|
||||
}
|
121
apps/viewer/src/test/assets/typebots/predefinedVariables.json
Normal file
121
apps/viewer/src/test/assets/typebots/predefinedVariables.json
Normal 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'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
|
||||
}
|
121
apps/viewer/src/test/assets/typebots/sendEmail.json
Normal file
121
apps/viewer/src/test/assets/typebots/sendEmail.json
Normal 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
|
||||
}
|
279
apps/viewer/src/test/assets/typebots/webhook.json
Normal file
279
apps/viewer/src/test/assets/typebots/webhook.json
Normal 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
|
||||
}
|
23
apps/viewer/src/test/utils/databaseActions.ts
Normal file
23
apps/viewer/src/test/utils/databaseActions.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
4
apps/viewer/src/test/utils/playwright.ts
Normal file
4
apps/viewer/src/test/utils/playwright.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import path from 'path'
|
||||
|
||||
export const getTestAsset = (name: string) =>
|
||||
path.join(__dirname, '..', 'assets', name)
|
53
apps/viewer/src/utils/api/dbRules.ts
Normal file
53
apps/viewer/src/utils/api/dbRules.ts
Normal 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 } },
|
||||
},
|
||||
},
|
||||
})
|
Reference in New Issue
Block a user