2
0

⚗️ Implement bot v2 MVP (#194)

Closes #190
This commit is contained in:
Baptiste Arnaud
2022-12-22 17:02:34 +01:00
committed by GitHub
parent e55823e011
commit 1a3869ae6d
202 changed files with 8060 additions and 1152 deletions

View File

@ -1,4 +1,4 @@
const emailRegex =
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
export const validateEmail = (email: string) => emailRegex.test(email)

View File

@ -0,0 +1,117 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { parse } from 'papaparse'
import { readFileSync } from 'fs'
import { isDefined } from 'utils'
import {
createWorkspaces,
importTypebotInDatabase,
injectFakeResults,
} from 'utils/playwright/databaseActions'
import { getTestAsset } from '@/test/utils/playwright'
import { Plan } from 'db'
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(`/next/${typebotId}-public`)
await page
.locator(`input[type="file"]`)
.setInputFiles([
getTestAsset('typebots/api.json'),
getTestAsset('typebots/fileUpload.json'),
getTestAsset('typebots/hugeGroup.json'),
])
await expect(page.locator(`text="3"`)).toBeVisible()
await page.locator('text="Upload 3 files"').click()
await expect(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()
})
test.describe('Storage limit is reached', () => {
const typebotId = cuid()
const workspaceId = cuid()
test.beforeAll(async () => {
await createWorkspaces([{ id: workspaceId, plan: Plan.STARTER }])
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
workspaceId,
})
await injectFakeResults({
typebotId,
count: 20,
fakeStorage: THREE_GIGABYTES,
})
})
test("shouldn't upload anything if limit has been reached", async ({
page,
}) => {
await page.goto(`/next/${typebotId}-public`)
await page
.locator(`input[type="file"]`)
.setInputFiles([
getTestAsset('typebots/api.json'),
getTestAsset('typebots/fileUpload.json'),
getTestAsset('typebots/hugeGroup.json'),
])
await expect(page.locator(`text="3"`)).toBeVisible()
await page.locator('text="Upload 3 files"').click()
await expect(page.locator(`text="3 files uploaded"`)).toBeVisible()
await page.evaluate(() =>
window.localStorage.setItem('workspaceId', 'starterWorkspace')
)
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text="150%"')).toBeVisible()
await expect(page.locator('text="api.json"')).toBeHidden()
})
})

View File

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

View File

@ -0,0 +1,128 @@
import { parseVariables } from '@/features/variables'
import prisma from '@/lib/prisma'
import { TRPCError } from '@trpc/server'
import {
PaymentInputOptions,
PaymentInputRuntimeOptions,
SessionState,
StripeCredentialsData,
} from 'models'
import Stripe from 'stripe'
import { decrypt } from 'utils/api/encryption'
export const computePaymentInputRuntimeOptions =
(state: SessionState) => (options: PaymentInputOptions) =>
createStripePaymentIntent(state)(options)
const createStripePaymentIntent =
(state: SessionState) =>
async (options: PaymentInputOptions): Promise<PaymentInputRuntimeOptions> => {
const {
isPreview,
typebot: { variables },
} = state
if (!options.credentialsId)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Missing credentialsId',
})
const stripeKeys = await getStripeInfo(options.credentialsId)
if (!stripeKeys)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Credentials not found',
})
const stripe = new Stripe(
isPreview && stripeKeys?.test?.secretKey
? stripeKeys.test.secretKey
: stripeKeys.live.secretKey,
{ apiVersion: '2022-11-15' }
)
const amount =
Number(parseVariables(variables)(options.amount)) *
(isZeroDecimalCurrency(options.currency) ? 1 : 100)
if (isNaN(amount))
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'Could not parse amount, make sure your block is configured correctly',
})
// Create a PaymentIntent with the order amount and currency
const receiptEmail = parseVariables(variables)(
options.additionalInformation?.email
)
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: options.currency,
receipt_email: receiptEmail === '' ? undefined : receiptEmail,
automatic_payment_methods: {
enabled: true,
},
})
if (!paymentIntent.client_secret)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Could not create payment intent',
})
return {
paymentIntentSecret: paymentIntent.client_secret,
publicKey:
isPreview && stripeKeys.test?.publicKey
? stripeKeys.test.publicKey
: stripeKeys.live.publicKey,
amountLabel: `${
amount / (isZeroDecimalCurrency(options.currency) ? 1 : 100)
}${currencySymbols[options.currency] ?? ` ${options.currency}`}`,
}
}
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)
const currencySymbols: { [key: string]: string } = {
USD: '$',
EUR: '€',
CRC: '₡',
GBP: '£',
ILS: '₪',
INR: '₹',
JPY: '¥',
KRW: '₩',
NGN: '₦',
PHP: '₱',
PLN: 'zł',
PYG: '₲',
THB: '฿',
UAH: '₴',
VND: '₫',
}

View File

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

View File

@ -1 +1 @@
export { validatePhoneNumber } from './utils/validatePhoneNumber'
export * from './utils'

View File

@ -0,0 +1,4 @@
import phone from 'phone'
export const formatPhoneNumber = (phoneNumber: string) =>
phone(phoneNumber).phoneNumber

View File

@ -0,0 +1,2 @@
export * from './formatPhoneNumber'
export * from './validatePhoneNumber'

View File

@ -1,4 +1,4 @@
const phoneRegex = /^\+?[0-9]{6,}$/
import { phone } from 'phone'
export const validatePhoneNumber = (phoneNumber: string) =>
phoneRegex.test(phoneNumber)
phone(phoneNumber).isValid

View File

@ -0,0 +1,30 @@
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'
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(`/next/${typebotId}-public`)
await page.getByRole('button', { name: 'Go' }).click()
await expect(page.locator('#chatwoot_live_chat_widget')).toBeVisible()
})

View File

@ -1,9 +1,16 @@
import { SessionState, GoogleSheetsGetOptions, VariableWithValue } from 'models'
import { saveErrorLog, saveSuccessLog } from '@/features/logs/api'
import {
SessionState,
GoogleSheetsGetOptions,
VariableWithValue,
ComparisonOperators,
LogicalOperator,
} from 'models'
import { saveErrorLog } from '@/features/logs/api'
import { getAuthenticatedGoogleDoc } from './helpers'
import { parseVariables, updateVariables } from '@/features/variables'
import { isNotEmpty, byId } from 'utils'
import { updateVariables } from '@/features/variables'
import { isNotEmpty, byId, isDefined } from 'utils'
import { ExecuteIntegrationResponse } from '@/features/chat'
import type { GoogleSpreadsheetRow } from 'google-spreadsheet'
export const getRow = async (
state: SessionState,
@ -12,56 +19,51 @@ export const getRow = async (
options,
}: { outgoingEdgeId?: string; options: GoogleSheetsGetOptions }
): Promise<ExecuteIntegrationResponse> => {
const { sheetId, cellsToExtract, referenceCell } = options
const { sheetId, cellsToExtract, referenceCell, filter } = options
if (!cellsToExtract || !sheetId || !referenceCell) return { outgoingEdgeId }
const variables = state.typebot.variables
const resultId = state.result.id
const resultId = state.result?.id
const doc = await getAuthenticatedGoogleDoc({
credentialsId: options.credentialsId,
spreadsheetId: options.spreadsheetId,
})
const parsedReferenceCell = {
column: referenceCell.column,
value: parseVariables(variables)(referenceCell.value),
}
const extractingColumns = cellsToExtract
.map((cell) => cell.column)
.filter(isNotEmpty)
try {
await doc.loadInfo()
const sheet = doc.sheetsById[sheetId]
const rows = await sheet.getRows()
const row = rows.find(
(row) =>
row[parsedReferenceCell.column as string] === parsedReferenceCell.value
const filteredRows = rows.filter((row) =>
referenceCell
? row[referenceCell.column as string] === referenceCell.value
: matchFilter(row, filter)
)
if (!row) {
if (filteredRows.length === 0) {
await saveErrorLog({
resultId,
message: "Couldn't find reference cell",
})
return { outgoingEdgeId }
}
const data: { [key: string]: string } = {
...extractingColumns.reduce(
(obj, column) => ({ ...obj, [column]: row[column] }),
{}
),
}
await saveSuccessLog({
resultId,
message: 'Succesfully fetched spreadsheet data',
})
const randomIndex = Math.floor(Math.random() * filteredRows.length)
const extractingColumns = cellsToExtract
.map((cell) => cell.column)
.filter(isNotEmpty)
const selectedRow = filteredRows
.map((row) =>
extractingColumns.reduce<{ [key: string]: string }>(
(obj, column) => ({ ...obj, [column]: row[column] }),
{}
)
)
.at(randomIndex)
if (!selectedRow) return { outgoingEdgeId }
const newVariables = options.cellsToExtract.reduce<VariableWithValue[]>(
(newVariables, cell) => {
const existingVariable = variables.find(byId(cell.variableId))
const value = data[cell.column ?? ''] ?? null
const value = selectedRow[cell.column ?? ''] ?? null
if (!existingVariable) return newVariables
return [
...newVariables,
@ -87,3 +89,56 @@ export const getRow = async (
}
return { outgoingEdgeId }
}
const matchFilter = (
row: GoogleSpreadsheetRow,
filter: GoogleSheetsGetOptions['filter']
) => {
return filter.logicalOperator === LogicalOperator.AND
? filter.comparisons.every(
(comparison) =>
comparison.column &&
matchComparison(
row[comparison.column],
comparison.comparisonOperator,
comparison.value
)
)
: filter.comparisons.some(
(comparison) =>
comparison.column &&
matchComparison(
row[comparison.column],
comparison.comparisonOperator,
comparison.value
)
)
}
const matchComparison = (
inputValue?: string,
comparisonOperator?: ComparisonOperators,
value?: string
) => {
if (!inputValue || !comparisonOperator || !value) return false
switch (comparisonOperator) {
case ComparisonOperators.CONTAINS: {
return inputValue.toLowerCase().includes(value.toLowerCase())
}
case ComparisonOperators.EQUAL: {
return inputValue === value
}
case ComparisonOperators.NOT_EQUAL: {
return inputValue !== value
}
case ComparisonOperators.GREATER: {
return parseFloat(inputValue) > parseFloat(value)
}
case ComparisonOperators.LESS: {
return parseFloat(inputValue) < parseFloat(value)
}
case ComparisonOperators.IS_SET: {
return isDefined(inputValue) && inputValue.length > 0
}
}
}

View File

@ -23,16 +23,18 @@ export const insertRow = async (
await doc.loadInfo()
const sheet = doc.sheetsById[options.sheetId]
await sheet.addRow(parsedValues)
await saveSuccessLog({
resultId: result.id,
message: 'Succesfully inserted row',
})
result &&
(await saveSuccessLog({
resultId: result.id,
message: 'Succesfully inserted row',
}))
} catch (err) {
await saveErrorLog({
resultId: result.id,
message: "Couldn't fetch spreadsheet data",
details: err,
})
result &&
(await saveErrorLog({
resultId: result.id,
message: "Couldn't fetch spreadsheet data",
details: err,
}))
}
return { outgoingEdgeId }
}

View File

@ -45,16 +45,18 @@ export const updateRow = async (
rows[updatingRowIndex][key] = parsedValues[key]
}
await rows[updatingRowIndex].save()
await saveSuccessLog({
resultId: result.id,
message: 'Succesfully updated row',
})
result &&
(await saveSuccessLog({
resultId: result.id,
message: 'Succesfully updated row',
}))
} catch (err) {
await saveErrorLog({
resultId: result.id,
message: "Couldn't fetch spreadsheet data",
details: err,
})
result &&
(await saveErrorLog({
resultId: result.id,
message: "Couldn't fetch spreadsheet data",
details: err,
}))
}
return { outgoingEdgeId }
}

View File

@ -26,7 +26,7 @@ export const executeSendEmailBlock = async (
const { variables } = typebot
await sendEmail({
typebotId: typebot.id,
resultId: result.id,
resultId: result?.id,
credentialsId: options.credentialsId,
recipients: options.recipients.map(parseVariables(variables)),
subject: parseVariables(variables)(options.subject ?? ''),
@ -59,7 +59,7 @@ const sendEmail = async ({
fileUrls,
}: SendEmailOptions & {
typebotId: string
resultId: string
resultId?: string
fileUrls?: string
}) => {
const { name: replyToName } = parseEmailRecipient(replyTo)
@ -114,7 +114,7 @@ const sendEmail = async ({
...emailBody,
}
try {
const info = await transporter.sendMail(email)
await transporter.sendMail(email)
await saveSuccessLog({
resultId,
message: 'Email successfully sent',
@ -169,7 +169,7 @@ const getEmailBody = async ({
resultId,
}: {
typebotId: string
resultId: string
resultId?: string
} & Pick<SendEmailOptions, 'isCustomBody' | 'isBodyCode' | 'body'>): Promise<
{ html?: string; text?: string } | undefined
> => {

View File

@ -0,0 +1,40 @@
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 { 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(`/next/${typebotId}-public`)
await page.locator('text=Send email').click()
await expect(page.getByText('Email sent!')).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await page.click('text="See logs"')
await expect(page.locator('text="Email successfully sent"')).toBeVisible()
})

View File

@ -35,37 +35,46 @@ export const executeWebhookBlock = async (
where: { id: block.webhookId },
})) as Webhook | null
if (!webhook) {
await saveErrorLog({
resultId: result.id,
message: `Couldn't find webhook`,
})
result &&
(await saveErrorLog({
resultId: result.id,
message: `Couldn't find webhook`,
}))
return { outgoingEdgeId: block.outgoingEdgeId }
}
const preparedWebhook = prepareWebhookAttributes(webhook, block.options)
const resultValues = await getResultValues(result.id)
const resultValues = result && (await getResultValues(result.id))
if (!resultValues) return { outgoingEdgeId: block.outgoingEdgeId }
const webhookResponse = await executeWebhook({ typebot })(
preparedWebhook,
typebot.variables,
block.groupId,
resultValues,
result.id
result?.id
)
const status = webhookResponse.statusCode.toString()
const isError = status.startsWith('4') || status.startsWith('5')
if (isError) {
await saveErrorLog({
resultId: result.id,
message: `Webhook returned error: ${webhookResponse.data}`,
details: JSON.stringify(webhookResponse.data, null, 2).substring(0, 1000),
})
result &&
(await saveErrorLog({
resultId: result.id,
message: `Webhook returned error: ${webhookResponse.data}`,
details: JSON.stringify(webhookResponse.data, null, 2).substring(
0,
1000
),
}))
} else {
await saveSuccessLog({
resultId: result.id,
message: `Webhook returned success: ${webhookResponse.data}`,
details: JSON.stringify(webhookResponse.data, null, 2).substring(0, 1000),
})
result &&
(await saveSuccessLog({
resultId: result.id,
message: `Webhook returned success: ${webhookResponse.data}`,
details: JSON.stringify(webhookResponse.data, null, 2).substring(
0,
1000
),
}))
}
const newVariables = block.options.responseVariableMapping.reduce<
@ -265,6 +274,7 @@ const convertKeyValueTableToObject = (
}, {})
}
// 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 }

View File

@ -20,29 +20,33 @@ test.describe('Bot', () => {
publicId: `${typebotId}-public`,
})
await createWebhook(typebotId, {
id: 'failing-webhook',
url: 'http://localhost:3001/api/mock/fail',
method: HttpMethod.POST,
})
try {
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: `{
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}}`,
})
await createWebhook(typebotId, {
id: 'full-body-webhook',
url: 'http://localhost:3000/api/mock/webhook-easy-config',
method: HttpMethod.POST,
body: `{{Full body}}`,
})
} catch (err) {
console.log(err)
}
})
test.afterEach(async () => {

View File

@ -0,0 +1,78 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { HttpMethod } from 'models'
import {
createWebhook,
deleteTypebots,
deleteWebhooks,
importTypebotInDatabase,
} from 'utils/playwright/databaseActions'
import { getTestAsset } from '@/test/utils/playwright'
const typebotId = cuid()
test.beforeEach(async () => {
await importTypebotInDatabase(getTestAsset('typebots/webhook.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
})
try {
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}}`,
})
} catch (err) {
console.log(err)
}
})
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(`/next/${typebotId}-public`)
await page.locator('text=Send failing webhook').click()
await page.locator('[placeholder="Type a name..."]').fill('John')
await page.locator('text="Send"').click()
await page.locator('[placeholder="Type an age..."]').fill('30')
await page.locator('text="Send"').click()
await page.locator('text="Male"').click()
await expect(
page.getByText('{"name":"John","age":25,"gender":"male"}')
).toBeVisible()
await expect(
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()
})

View File

@ -10,7 +10,9 @@ export const executeRedirect = (
if (!block.options?.url) return { outgoingEdgeId: block.outgoingEdgeId }
const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url))
return {
logic: { redirectUrl: formattedUrl },
logic: {
redirect: { url: formattedUrl, isNewTab: block.options.isNewTab },
},
outgoingEdgeId: block.outgoingEdgeId,
}
}

View File

@ -1,7 +1,13 @@
import { ExecuteLogicResponse } from '@/features/chat'
import { saveErrorLog } from '@/features/logs/api'
import prisma from '@/lib/prisma'
import { TypebotLinkBlock, Edge, SessionState, TypebotInSession } from 'models'
import {
TypebotLinkBlock,
Edge,
SessionState,
TypebotInSession,
Variable,
} from 'models'
import { byId } from 'utils'
export const executeTypebotLink = async (
@ -9,20 +15,22 @@ export const executeTypebotLink = async (
block: TypebotLinkBlock
): Promise<ExecuteLogicResponse> => {
if (!block.options.typebotId) {
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: 'Typebot ID is not specified',
})
state.result &&
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: 'Typebot ID is not specified',
})
return { outgoingEdgeId: block.outgoingEdgeId }
}
const linkedTypebot = await getLinkedTypebot(state, block.options.typebotId)
if (!linkedTypebot) {
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: `Typebot with ID ${block.options.typebotId} not found`,
})
state.result &&
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: `Typebot with ID ${block.options.typebotId} not found`,
})
return { outgoingEdgeId: block.outgoingEdgeId }
}
let newSessionState = addLinkedTypebotToState(state, block, linkedTypebot)
@ -32,11 +40,12 @@ export const executeTypebotLink = async (
linkedTypebot.groups.find((b) => b.blocks.some((s) => s.type === 'start'))
?.id
if (!nextGroupId) {
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: `Group with ID "${block.options.groupId}" not found`,
})
state.result &&
saveErrorLog({
resultId: state.result.id,
message: 'Failed to link typebot',
details: `Group with ID "${block.options.groupId}" not found`,
})
return { outgoingEdgeId: block.outgoingEdgeId }
}
const portalEdge: Edge = {
@ -65,29 +74,50 @@ const addLinkedTypebotToState = (
state: SessionState,
block: TypebotLinkBlock,
linkedTypebot: TypebotInSession
): SessionState => ({
...state,
typebot: {
...state.typebot,
groups: [...state.typebot.groups, ...linkedTypebot.groups],
variables: [...state.typebot.variables, ...linkedTypebot.variables],
edges: [...state.typebot.edges, ...linkedTypebot.edges],
},
linkedTypebots: {
typebots: [
...state.linkedTypebots.typebots.filter(
(existingTypebots) => existingTypebots.id !== linkedTypebot.id
),
],
queue: block.outgoingEdgeId
? [
...state.linkedTypebots.queue,
{ edgeId: block.outgoingEdgeId, typebotId: state.currentTypebotId },
]
: state.linkedTypebots.queue,
},
currentTypebotId: linkedTypebot.id,
})
): SessionState => {
const incomingVariables = fillVariablesWithExistingValues(
linkedTypebot.variables,
state.typebot.variables
)
return {
...state,
typebot: {
...state.typebot,
groups: [...state.typebot.groups, ...linkedTypebot.groups],
variables: [...state.typebot.variables, ...incomingVariables],
edges: [...state.typebot.edges, ...linkedTypebot.edges],
},
linkedTypebots: {
typebots: [
...state.linkedTypebots.typebots.filter(
(existingTypebots) => existingTypebots.id !== linkedTypebot.id
),
],
queue: block.outgoingEdgeId
? [
...state.linkedTypebots.queue,
{ edgeId: block.outgoingEdgeId, typebotId: state.currentTypebotId },
]
: state.linkedTypebots.queue,
},
currentTypebotId: linkedTypebot.id,
}
}
const fillVariablesWithExistingValues = (
variables: Variable[],
variablesWithValues: Variable[]
): Variable[] =>
variables.map((variable) => {
const matchedVariable = variablesWithValues.find(
(variableWithValue) => variableWithValue.name === variable.name
)
return {
...variable,
value: matchedVariable?.value ?? variable.value,
}
})
const getLinkedTypebot = async (
state: SessionState,

View File

@ -3,7 +3,7 @@ import test, { expect } from '@playwright/test'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
import { typebotViewer } from 'utils/playwright/testHelpers'
const typebotId = 'cl0ibhi7s0018n21aarlmg0cm'
const typebotId = 'cl0ibhi7s0018n21aarlmg0cm1'
const linkedTypebotId = 'cl0ibhv8d0130n21aw8doxhj5'
test.beforeAll(async () => {

View File

@ -0,0 +1,30 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
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(`/next/${typebotId}-public`)
await page.locator('input').fill('Hello there!')
await page.locator('input').press('Enter')
await expect(page.getByText('Cheers!')).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text=Hello there!')).toBeVisible()
})

View File

@ -1,58 +1,40 @@
import { checkChatsUsage } from '@/features/usage'
import { parsePrefilledVariables } from '@/features/variables'
import prisma from '@/lib/prisma'
import { publicProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Prisma } from 'db'
import {
chatReplySchema,
ChatSession,
PublicTypebotWithName,
PublicTypebot,
Result,
sendMessageInputSchema,
SessionState,
typebotSchema,
StartParams,
Typebot,
Variable,
} from 'models'
import { z } from 'zod'
import { continueBotFlow, getSession, startBotFlow } from '../utils'
export const sendMessageProcedure = publicProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/sendMessage',
path: '/sendMessage',
summary: 'Send a message',
description:
"To initiate a chat, don't provide a `sessionId` and enter any `message`.\n\nContinue the conversation by providing the `sessionId` and the `message` that should answer the previous question.\n\nSet the `isPreview` option to `true` to chat with the non-published version of the typebot.",
'To initiate a chat, do not provide a `sessionId` nor a `message`.\n\nContinue the conversation by providing the `sessionId` and the `message` that should answer the previous question.\n\nSet the `isPreview` option to `true` to chat with the non-published version of the typebot.',
},
})
.input(
z.object({
typebotId: z.string({
description:
'[How can I find my typebot ID?](https://docs.typebot.io/api#how-to-find-my-typebotid)',
}),
message: z.string().describe('The answer to the previous question'),
sessionId: z
.string()
.optional()
.describe(
'Session ID that you get from the initial chat request to a bot'
),
isPreview: z.boolean().optional(),
})
)
.output(
chatReplySchema.and(
z.object({
sessionId: z.string().nullish(),
typebot: typebotSchema.pick({ theme: true, settings: true }).nullish(),
})
)
)
.query(async ({ input: { typebotId, sessionId, message } }) => {
.input(sendMessageInputSchema)
.output(chatReplySchema)
.query(async ({ input: { sessionId, message, startParams } }) => {
const session = sessionId ? await getSession(sessionId) : null
if (!session) {
const { sessionId, typebot, messages, input } = await startSession(
typebotId
)
const { sessionId, typebot, messages, input, resultId } =
await startSession(startParams)
return {
sessionId,
typebot: typebot
@ -60,14 +42,14 @@ export const sendMessageProcedure = publicProcedure
theme: typebot.theme,
settings: typebot.settings,
}
: null,
: undefined,
messages,
input,
resultId,
}
} else {
const { messages, input, logic, newSessionState } = await continueBotFlow(
session.state
)(message)
const { messages, input, logic, newSessionState, integrations } =
await continueBotFlow(session.state)(message)
await prisma.chatSession.updateMany({
where: { id: session.id },
@ -80,59 +62,103 @@ export const sendMessageProcedure = publicProcedure
messages,
input,
logic,
integrations,
}
}
})
const startSession = async (typebotId: string) => {
const typebot = await prisma.typebot.findUnique({
where: { id: typebotId },
select: {
publishedTypebot: true,
name: true,
isClosed: true,
isArchived: true,
id: true,
},
})
const startSession = async (startParams?: StartParams) => {
if (!startParams?.typebotId)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No typebotId provided in startParams',
})
const typebotQuery = startParams.isPreview
? await prisma.typebot.findUnique({
where: { id: startParams.typebotId },
select: {
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
isArchived: true,
},
})
: await prisma.typebot.findUnique({
where: { id: startParams.typebotId },
select: {
publishedTypebot: {
select: {
groups: true,
edges: true,
settings: true,
theme: true,
variables: true,
},
},
name: true,
isClosed: true,
isArchived: true,
id: true,
},
})
if (!typebot?.publishedTypebot || typebot.isArchived)
const typebot =
typebotQuery && 'publishedTypebot' in typebotQuery
? (typebotQuery.publishedTypebot as Pick<
PublicTypebot,
'groups' | 'edges' | 'settings' | 'theme' | 'variables'
>)
: (typebotQuery as Pick<
Typebot,
'groups' | 'edges' | 'settings' | 'theme' | 'variables' | 'isArchived'
>)
if (!typebot)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Typebot not found',
})
if (typebot.isClosed)
if ('isClosed' in typebot && typebot.isClosed)
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Typebot is closed',
})
const result = (await prisma.result.create({
data: { isCompleted: false, typebotId },
select: {
id: true,
variables: true,
hasStarted: true,
},
})) as Pick<Result, 'id' | 'variables' | 'hasStarted'>
const hasReachedLimit = !startParams.isPreview
? await checkChatsUsage(startParams.typebotId)
: false
const publicTypebot = typebot.publishedTypebot as PublicTypebotWithName
if (hasReachedLimit)
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Your workspace reached its chat limit',
})
const startVariables = startParams.prefilledVariables
? parsePrefilledVariables(typebot.variables, startParams.prefilledVariables)
: typebot.variables
const result = await getResult({ ...startParams, startVariables })
const initialState: SessionState = {
typebot: {
id: publicTypebot.typebotId,
groups: publicTypebot.groups,
edges: publicTypebot.edges,
variables: publicTypebot.variables,
id: startParams.typebotId,
groups: typebot.groups,
edges: typebot.edges,
variables: startVariables,
},
linkedTypebots: {
typebots: [],
queue: [],
},
result: { id: result.id, variables: [], hasStarted: false },
result: result
? { id: result.id, variables: result.variables, hasStarted: false }
: undefined,
isPreview: false,
currentTypebotId: publicTypebot.typebotId,
currentTypebotId: startParams.typebotId,
}
const {
@ -145,8 +171,6 @@ const startSession = async (typebotId: string) => {
if (!input)
return {
messages,
typebot: null,
sessionId: null,
logic,
}
@ -165,13 +189,47 @@ const startSession = async (typebotId: string) => {
})) as ChatSession
return {
resultId: result?.id,
sessionId: session.id,
typebot: {
theme: publicTypebot.theme,
settings: publicTypebot.settings,
theme: typebot.theme,
settings: typebot.settings,
},
messages,
input,
logic,
}
}
const getResult = async ({
typebotId,
isPreview,
resultId,
startVariables,
}: Pick<StartParams, 'isPreview' | 'resultId' | 'typebotId'> & {
startVariables: Variable[]
}) => {
if (isPreview) return undefined
const data = {
isCompleted: false,
typebotId: typebotId,
variables: { set: startVariables.filter((variable) => variable.value) },
} satisfies Prisma.ResultUncheckedCreateInput
const select = {
id: true,
variables: true,
hasStarted: true,
} satisfies Prisma.ResultSelect
return (
resultId
? await prisma.result.update({
where: { id: resultId },
data,
select,
})
: await prisma.result.create({
data,
select,
})
) as Pick<Result, 'id' | 'variables' | 'hasStarted'>
}

View File

@ -1,11 +1,16 @@
import { validateButtonInput } from '@/features/blocks/inputs/buttons/api'
import { validateEmail } from '@/features/blocks/inputs/email/api'
import { validatePhoneNumber } from '@/features/blocks/inputs/phone/api'
import {
formatPhoneNumber,
validatePhoneNumber,
} from '@/features/blocks/inputs/phone/api'
import { validateUrl } from '@/features/blocks/inputs/url/api'
import { parseVariables } from '@/features/variables'
import prisma from '@/lib/prisma'
import { TRPCError } from '@trpc/server'
import {
Block,
BlockType,
BubbleBlockType,
ChatReply,
InputBlock,
@ -20,7 +25,7 @@ import { getNextGroup } from './getNextGroup'
export const continueBotFlow =
(state: SessionState) =>
async (
reply: string
reply?: string
): Promise<ChatReply & { newSessionState?: SessionState }> => {
const group = state.typebot.groups.find(
(group) => group.id === state.currentBlock?.groupId
@ -30,7 +35,7 @@ export const continueBotFlow =
(block) => block.id === state.currentBlock?.blockId
) ?? -1
const block = blockIndex > 0 ? group?.blocks[blockIndex ?? 0] : null
const block = blockIndex >= 0 ? group?.blocks[blockIndex ?? 0] : null
if (!block || !group)
throw new TRPCError({
@ -44,9 +49,15 @@ export const continueBotFlow =
message: 'Current block is not an input block',
})
if (!isInputValid(reply, block)) return parseRetryMessage(block)
const formattedReply = formatReply(reply, block.type)
const newVariables = await processAndSaveAnswer(state, block)(reply)
if (!formattedReply || !isReplyValid(formattedReply, block))
return parseRetryMessage(block)
const newVariables = await processAndSaveAnswer(
state,
block
)(formattedReply)
const newSessionState = {
...state,
@ -58,15 +69,15 @@ export const continueBotFlow =
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
if (groupHasMoreBlocks) {
const nextEdgeId = getOutgoingEdgeId(newSessionState)(block, formattedReply)
if (groupHasMoreBlocks && !nextEdgeId) {
return executeGroup(newSessionState)({
...group,
blocks: group.blocks.slice(blockIndex + 1),
})
}
const nextEdgeId = block.outgoingEdgeId
if (!nextEdgeId && state.linkedTypebots.queue.length === 0)
return { messages: [] }
@ -80,7 +91,7 @@ export const continueBotFlow =
const processAndSaveAnswer =
(state: Pick<SessionState, 'result' | 'typebot'>, block: InputBlock) =>
async (reply: string): Promise<Variable[]> => {
await saveAnswer(state.result.id, block)(reply)
state.result && (await saveAnswer(state.result.id, block)(reply))
const newVariables = saveVariableValueIfAny(state, block)(reply)
return newVariables
}
@ -105,22 +116,26 @@ const saveVariableValueIfAny =
]
}
const parseRetryMessage = (block: InputBlock) => ({
messages: [
{
type: BubbleBlockType.TEXT,
content: {
plainText:
'retryMessageContent' in block.options
? block.options.retryMessageContent
: 'Invalid message. Please, try again.',
richText: [],
html: '',
const parseRetryMessage = (
block: InputBlock
): Pick<ChatReply, 'messages' | 'input'> => {
const retryMessage =
'retryMessageContent' in block.options && block.options.retryMessageContent
? block.options.retryMessageContent
: 'Invalid message. Please, try again.'
return {
messages: [
{
type: BubbleBlockType.TEXT,
content: {
plainText: retryMessage,
html: `<div>${retryMessage}</div>`,
},
},
},
],
input: block,
})
],
input: block,
}
}
const saveAnswer =
(resultId: string, block: InputBlock) => async (reply: string) => {
@ -135,7 +150,35 @@ const saveAnswer =
})
}
export const isInputValid = (inputValue: string, block: Block): boolean => {
const getOutgoingEdgeId =
({ typebot: { variables } }: Pick<SessionState, 'typebot'>) =>
(block: InputBlock, reply?: string) => {
if (
block.type === InputBlockType.CHOICE &&
!block.options.isMultipleChoice &&
reply
) {
const matchedItem = block.items.find(
(item) => parseVariables(variables)(item.content) === reply
)
if (matchedItem?.outgoingEdgeId) return matchedItem.outgoingEdgeId
}
return block.outgoingEdgeId
}
export const formatReply = (
inputValue: string | undefined,
blockType: BlockType
): string | null => {
if (!inputValue) return null
switch (blockType) {
case InputBlockType.PHONE:
return formatPhoneNumber(inputValue)
}
return inputValue
}
export const isReplyValid = (inputValue: string, block: Block): boolean => {
switch (block.type) {
case InputBlockType.EMAIL:
return validateEmail(inputValue)

View File

@ -2,13 +2,17 @@ import { parseVariables } from '@/features/variables'
import {
BubbleBlock,
BubbleBlockType,
ChatMessageContent,
ChatMessage,
ChatReply,
Group,
InputBlock,
InputBlockType,
RuntimeOptions,
SessionState,
} from 'models'
import {
isBubbleBlock,
isDefined,
isInputBlock,
isIntegrationBlock,
isLogicBlock,
@ -16,6 +20,7 @@ import {
import { executeLogic } from './executeLogic'
import { getNextGroup } from './getNextGroup'
import { executeIntegration } from './executeIntegration'
import { computePaymentInputRuntimeOptions } from '@/features/blocks/inputs/payment/api'
export const executeGroup =
(state: SessionState, currentReply?: ChatReply) =>
@ -33,17 +38,20 @@ export const executeGroup =
nextEdgeId = block.outgoingEdgeId
if (isBubbleBlock(block)) {
messages.push({
type: block.type,
content: parseBubbleBlockContent(newSessionState)(block),
})
messages.push(parseBubbleBlockContent(newSessionState)(block))
continue
}
if (isInputBlock(block))
return {
messages,
input: block,
input: {
...block,
runtimeOptions: await computeRuntimeOptions(newSessionState)(block),
prefilledValue: getPrefilledInputValue(
newSessionState.typebot.variables
)(block),
},
newSessionState: {
...newSessionState,
currentBlock: {
@ -53,9 +61,9 @@ export const executeGroup =
},
}
const executionResponse = isLogicBlock(block)
? await executeLogic(state)(block)
? await executeLogic(newSessionState)(block)
: isIntegrationBlock(block)
? await executeIntegration(state)(block)
? await executeIntegration(newSessionState)(block)
: null
if (!executionResponse) continue
@ -84,30 +92,50 @@ export const executeGroup =
)
}
const computeRuntimeOptions =
(state: SessionState) =>
(block: InputBlock): Promise<RuntimeOptions> | undefined => {
switch (block.type) {
case InputBlockType.PAYMENT: {
return computePaymentInputRuntimeOptions(state)(block.options)
}
}
}
const parseBubbleBlockContent =
({ typebot: { variables } }: SessionState) =>
(block: BubbleBlock): ChatMessageContent => {
(block: BubbleBlock): ChatMessage => {
switch (block.type) {
case BubbleBlockType.TEXT: {
const plainText = parseVariables(variables)(block.content.plainText)
const html = parseVariables(variables)(block.content.html)
return { plainText, html }
return { type: block.type, content: { plainText, html } }
}
case BubbleBlockType.IMAGE: {
const url = parseVariables(variables)(block.content.url)
return { url }
return { type: block.type, content: { ...block.content, url } }
}
case BubbleBlockType.VIDEO: {
const url = parseVariables(variables)(block.content.url)
return { url }
return { type: block.type, content: { ...block.content, url } }
}
case BubbleBlockType.AUDIO: {
const url = parseVariables(variables)(block.content.url)
return { url }
return { type: block.type, content: { ...block.content, url } }
}
case BubbleBlockType.EMBED: {
const url = parseVariables(variables)(block.content.url)
return { url }
return { type: block.type, content: { ...block.content, url } }
}
}
}
const getPrefilledInputValue =
(variables: SessionState['typebot']['variables']) => (block: InputBlock) => {
return (
variables.find(
(variable) =>
variable.id === block.options.variableId && isDefined(variable.value)
)?.value ?? undefined
)
}

View File

@ -1,7 +1,8 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { HttpMethod } from 'models'
import prisma from '@/lib/prisma'
import { HttpMethod, SendMessageInput } from 'models'
import {
createWebhook,
deleteTypebots,
@ -9,10 +10,14 @@ import {
importTypebotInDatabase,
} from 'utils/playwright/databaseActions'
const typebotId = cuid()
const publicId = `${typebotId}-public`
test.afterEach(async () => {
await deleteWebhooks(['chat-webhook-id'])
await deleteTypebots(['chat-sub-bot'])
})
test.beforeEach(async () => {
test('API chat execution should work on preview bot', async ({ request }) => {
const typebotId = cuid()
const publicId = `${typebotId}-public`
await importTypebotInDatabase(getTestAsset('typebots/chat/main.json'), {
id: typebotId,
publicId,
@ -26,25 +31,64 @@ test.beforeEach(async () => {
method: HttpMethod.GET,
url: 'https://api.chucknorris.io/jokes/random',
})
await test.step('Start the chat', async () => {
const { sessionId, messages, input, resultId } = await (
await request.post(`/api/v1/sendMessage`, {
data: {
startParams: {
typebotId,
isPreview: true,
},
// TODO: replace with satisfies once compatible with playwright
} as SendMessageInput,
})
).json()
expect(resultId).toBeUndefined()
expect(sessionId).toBeDefined()
expect(messages[0].content.plainText).toBe('Hi there! 👋')
expect(messages[1].content.plainText).toBe("Welcome. What's your name?")
expect(input.type).toBe('text input')
})
})
test.afterEach(async () => {
await deleteWebhooks(['chat-webhook-id'])
await deleteTypebots(['chat-sub-bot'])
})
test('API chat execution should work', async ({ request }) => {
test('API chat execution should work on published bot', async ({ request }) => {
const typebotId = cuid()
const publicId = `${typebotId}-public`
await importTypebotInDatabase(getTestAsset('typebots/chat/main.json'), {
id: typebotId,
publicId,
})
await importTypebotInDatabase(getTestAsset('typebots/chat/linkedBot.json'), {
id: 'chat-sub-bot',
publicId: 'chat-sub-bot-public',
})
await createWebhook(typebotId, {
id: 'chat-webhook-id',
method: HttpMethod.GET,
url: 'https://api.chucknorris.io/jokes/random',
})
let chatSessionId: string
await test.step('Start the chat', async () => {
const { sessionId, messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
const { sessionId, messages, input, resultId } = await (
await request.post(`/api/v1/sendMessage`, {
data: {
message: 'Hi',
},
startParams: {
typebotId,
},
// TODO: replace with satisfies once compatible with playwright
} as SendMessageInput,
})
).json()
chatSessionId = sessionId
expect(resultId).toBeDefined()
const result = await prisma.result.findUnique({
where: {
id: resultId,
},
})
expect(result).toBeDefined()
expect(sessionId).toBeDefined()
expect(messages[0].content.plainText).toBe('Hi there! 👋')
expect(messages[1].content.plainText).toBe("Welcome. What's your name?")
@ -53,7 +97,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Name question', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: 'John', sessionId: chatSessionId },
})
).json()
@ -64,7 +108,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Age question', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: '24', sessionId: chatSessionId },
})
).json()
@ -78,7 +122,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Rating question', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: '8', sessionId: chatSessionId },
})
).json()
@ -90,7 +134,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Email question with wrong input', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: 'invalid email', sessionId: chatSessionId },
})
).json()
@ -102,7 +146,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Email question with valid input', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: 'typebot@email.com', sessionId: chatSessionId },
})
).json()
@ -112,7 +156,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer URL question', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: 'https://typebot.io', sessionId: chatSessionId },
})
).json()
@ -122,7 +166,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Buttons question with invalid choice', async () => {
const { messages, input } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: 'Yolo', sessionId: chatSessionId },
})
).json()
@ -134,7 +178,7 @@ test('API chat execution should work', async ({ request }) => {
await test.step('Answer Buttons question with invalid choice', async () => {
const { messages } = await (
await request.post(`/api/v1/typebots/${typebotId}/sendMessage`, {
await request.post(`/api/v1/sendMessage`, {
data: { message: 'Yes', sessionId: chatSessionId },
})
).json()

View File

@ -0,0 +1,27 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { importTypebotInDatabase } from 'utils/playwright/databaseActions'
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(`/next/${typebotId}-public`)
await page.locator('input').fill('Baptiste')
await page.locator('input').press('Enter')
await page.locator('input').fill('26')
await page.locator('input').press('Enter')
await 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()
})

View File

@ -0,0 +1,179 @@
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'
test('Result should be overwritten on page refresh', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
const [, response] = await Promise.all([
page.goto(`/next/${typebotId}-public`),
page.waitForResponse(/sendMessage/),
])
const { resultId } = await response.json()
expect(resultId).toBeDefined()
await expect(page.getByRole('textbox')).toBeVisible()
const [, secondResponse] = await Promise.all([
page.reload(),
page.waitForResponse(/sendMessage/),
])
const { resultId: secondResultId } = await secondResponse.json()
expect(secondResultId).toBe(resultId)
})
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,
}),
},
])
const [, response] = await Promise.all([
page.goto(`/next/${typebotId}-public`),
page.waitForResponse(/sendMessage/),
])
const { resultId } = await response.json()
expect(resultId).toBeDefined()
await expect(page.getByRole('textbox')).toBeVisible()
const [, secondResponse] = await Promise.all([
page.reload(),
page.waitForResponse(/sendMessage/),
])
const { resultId: secondResultId } = await secondResponse.json()
expect(secondResultId).not.toBe(resultId)
})
})
test('Hide query params', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await page.goto(`/next/${typebotId}-public?Name=John`)
await page.waitForTimeout(1000)
expect(page.url()).toEqual(`http://localhost:3001/next/${typebotId}-public`)
await updateTypebot({
id: typebotId,
settings: {
...defaultSettings,
general: { ...defaultSettings.general, isHideQueryParamsEnabled: false },
},
})
await page.goto(`/next/${typebotId}-public?Name=John`)
await page.waitForTimeout(1000)
expect(page.url()).toEqual(
`http://localhost:3001/next/${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(`/next/${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(`/next/${typebotId}-public`)
expect(
await page.evaluate(`document.querySelector('title').textContent`)
).toBe(customMetadata.title)
expect(
await page.evaluate(
() =>
(document.querySelector('meta[name="description"]') as HTMLMetaElement)
.content
)
).toBe(customMetadata.description)
expect(
await page.evaluate(
() =>
(document.querySelector('meta[property="og:image"]') as HTMLMetaElement)
.content
)
).toBe(customMetadata.imageUrl)
expect(
await page.evaluate(() =>
(
document.querySelector('link[rel="icon"]') as HTMLLinkElement
).getAttribute('href')
)
).toBe(customMetadata.favIconUrl)
await expect(
page.locator(
`input[placeholder="${defaultTextInputOptions.labels.placeholder}"]`
)
).toBeVisible()
expect(
await page.evaluate(
() =>
(document.querySelector('meta[name="author"]') as HTMLMetaElement)
.content
)
).toBe('John Doe')
})

View File

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

View File

@ -0,0 +1,70 @@
import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { Plan } from 'db'
import { defaultSettings } from 'models'
import {
createWorkspaces,
importTypebotInDatabase,
injectFakeResults,
} from 'utils/playwright/databaseActions'
test('should not start if chat limit is reached', async ({ page, context }) => {
await test.step('Free plan', async () => {
const workspaceId = cuid()
const typebotId = cuid()
await createWorkspaces([{ id: workspaceId, plan: Plan.FREE }])
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
workspaceId,
})
await injectFakeResults({ typebotId, count: 400 })
await page.goto(`/next/${typebotId}-public`)
await expect(page.locator('text="This bot is now closed."')).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text="133%"')).toBeVisible()
})
await test.step('Lifetime plan', async () => {
const workspaceId = cuid()
const typebotId = cuid()
await createWorkspaces([{ id: workspaceId, plan: Plan.LIFETIME }])
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
workspaceId,
})
await injectFakeResults({ typebotId, count: 3000 })
await page.goto(`/next/${typebotId}-public`)
await expect(page.locator('text="Hey there, upload please"')).toBeVisible()
})
await test.step('Custom plan', async () => {
const workspaceId = cuid()
const typebotId = cuid()
await createWorkspaces([
{ id: workspaceId, plan: Plan.CUSTOM, customChatsLimit: 1000 },
])
await importTypebotInDatabase(getTestAsset('typebots/fileUpload.json'), {
id: typebotId,
publicId: `${typebotId}-public`,
workspaceId,
settings: {
...defaultSettings,
general: {
...defaultSettings.general,
isNewResultOnRefreshEnabled: true,
},
},
})
const page = await context.newPage()
await page.goto(`/next/${typebotId}-public`)
await expect(page.locator('text="Hey there, upload please"')).toBeVisible()
await injectFakeResults({ typebotId, count: 2000 })
await page.goto(`/next/${typebotId}-public`)
await expect(page.locator('text="This bot is now closed."')).toBeVisible()
await page.goto(`${process.env.NEXTAUTH_URL}/typebots/${typebotId}/results`)
await expect(page.locator('text="200%"')).toBeVisible()
})
})

View File

@ -0,0 +1,130 @@
import prisma from '@/lib/prisma'
import { WorkspaceRole } from 'db'
import {
sendAlmostReachedChatsLimitEmail,
sendReachedChatsLimitEmail,
} from 'emails'
import { env, getChatsLimit, isDefined } from 'utils'
const LIMIT_EMAIL_TRIGGER_PERCENT = 0.8
export const checkChatsUsage = async (typebotId: string) => {
const typebot = await prisma.typebot.findUnique({
where: {
id: typebotId,
},
include: {
workspace: {
select: {
id: true,
plan: true,
additionalChatsIndex: true,
chatsLimitFirstEmailSentAt: true,
chatsLimitSecondEmailSentAt: true,
customChatsLimit: true,
},
},
},
})
const workspace = typebot?.workspace
if (!workspace) return false
const chatsLimit = getChatsLimit(workspace)
if (chatsLimit === -1) return
const now = new Date()
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const firstDayOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1)
const chatsCount = await prisma.$transaction(async (tx) => {
const typebotIds = await tx.typebot.findMany({
where: {
workspaceId: workspace.id,
},
select: { id: true },
})
return tx.result.count({
where: {
typebotId: { in: typebotIds.map((typebot) => typebot.id) },
hasStarted: true,
createdAt: { gte: firstDayOfMonth, lte: firstDayOfNextMonth },
},
})
})
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() },
})
}

View File

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

View File

@ -1,6 +1,8 @@
import prisma from '@/lib/prisma'
import {
SessionState,
StartParams,
Typebot,
Variable,
VariableWithUnknowValue,
VariableWithValue,
@ -99,6 +101,19 @@ export const parseVariablesInObject = (
}
}, {})
export const parsePrefilledVariables = (
variables: Typebot['variables'],
prefilledVariables: NonNullable<StartParams['prefilledVariables']>
): Variable[] =>
variables.map((variable) => {
const prefilledVariable = prefilledVariables[variable.name]
if (!prefilledVariable) return variable
return {
...variable,
value: safeStringify(prefilledVariable),
}
})
export const updateVariables =
(state: SessionState) =>
async (newVariables: VariableWithUnknowValue[]): Promise<SessionState> => ({
@ -107,10 +122,12 @@ export const updateVariables =
...state.typebot,
variables: updateTypebotVariables(state)(newVariables),
},
result: {
...state.result,
variables: await updateResultVariables(state)(newVariables),
},
result: state.result
? {
...state.result,
variables: await updateResultVariables(state)(newVariables),
}
: undefined,
})
const updateResultVariables =
@ -118,6 +135,7 @@ const updateResultVariables =
async (
newVariables: VariableWithUnknowValue[]
): Promise<VariableWithValue[]> => {
if (!result) return []
const serializedNewVariables = newVariables.map((variable) => ({
...variable,
value: safeStringify(variable.value),

View File

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