⚗️ 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()
})