2
0

♻️ Re-organize workspace folders

This commit is contained in:
Baptiste Arnaud
2023-03-15 08:35:16 +01:00
parent 25c367901f
commit cbc8194f19
987 changed files with 2716 additions and 2770 deletions

View File

@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ['custom'],
}

View File

@ -0,0 +1,36 @@
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto'
const algorithm = 'aes-256-gcm'
const secretKey = process.env.ENCRYPTION_SECRET
export const encrypt = (
data: object
): { encryptedData: string; iv: string } => {
if (!secretKey) throw new Error(`ENCRYPTION_SECRET is not in environment`)
const iv = randomBytes(16)
const cipher = createCipheriv(algorithm, secretKey, iv)
const dataString = JSON.stringify(data)
const encryptedData =
cipher.update(dataString, 'utf8', 'hex') + cipher.final('hex')
const tag = cipher.getAuthTag()
return {
encryptedData,
iv: iv.toString('hex') + '.' + tag.toString('hex'),
}
}
export const decrypt = (encryptedData: string, auth: string): object => {
if (!secretKey) throw new Error(`ENCRYPTION_SECRET is not in environment`)
const [iv, tag] = auth.split('.')
const decipher = createDecipheriv(
algorithm,
secretKey,
Buffer.from(iv, 'hex')
)
decipher.setAuthTag(Buffer.from(tag, 'hex'))
return JSON.parse(
(
decipher.update(Buffer.from(encryptedData, 'hex')) + decipher.final('hex')
).toString()
)
}

View File

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

View File

@ -0,0 +1,53 @@
import { config, Endpoint, S3 } from 'aws-sdk'
type GeneratePresignedUrlProps = {
filePath: string
fileType?: string
sizeLimit?: number
}
const tenMB = 10 * 1024 * 1024
const tenMinutes = 10 * 60
export const generatePresignedUrl = ({
filePath,
fileType,
sizeLimit = tenMB,
}: GeneratePresignedUrlProps): S3.PresignedPost => {
if (
!process.env.S3_ENDPOINT ||
!process.env.S3_ACCESS_KEY ||
!process.env.S3_SECRET_KEY
)
throw new Error(
'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY'
)
const sslEnabled =
process.env.S3_SSL && process.env.S3_SSL === 'false' ? false : true
config.update({
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
region: process.env.S3_REGION,
sslEnabled,
})
const protocol = sslEnabled ? 'https' : 'http'
const s3 = new S3({
endpoint: new Endpoint(
`${protocol}://${process.env.S3_ENDPOINT}${
process.env.S3_PORT ? `:${process.env.S3_PORT}` : ''
}`
),
})
const presignedUrl = s3.createPresignedPost({
Bucket: process.env.S3_BUCKET ?? 'typebot',
Fields: {
key: filePath,
'Content-Type': fileType,
},
Expires: tenMinutes,
Conditions: [['content-length-range', 0, sizeLimit]],
})
return presignedUrl
}

38
packages/lib/api/utils.ts Normal file
View File

@ -0,0 +1,38 @@
import { NextApiRequest, NextApiResponse } from 'next'
export const methodNotAllowed = (
res: NextApiResponse,
customMessage?: string
) => res.status(405).json({ message: customMessage ?? 'Method Not Allowed' })
export const notAuthenticated = (
res: NextApiResponse,
customMessage?: string
) => res.status(401).json({ message: customMessage ?? 'Not authenticated' })
export const notFound = (res: NextApiResponse, customMessage?: string) =>
res.status(404).json({ message: customMessage ?? 'Not found' })
export const badRequest = (res: NextApiResponse, customMessage?: any) =>
res.status(400).json({ message: customMessage ?? 'Bad Request' })
export const forbidden = (res: NextApiResponse, customMessage?: string) =>
res.status(403).json({ message: customMessage ?? 'Forbidden' })
export const initMiddleware =
(
handler: (
req: NextApiRequest,
res: NextApiResponse,
middleware: (result: any) => void
) => void
) =>
(req: any, res: any) =>
new Promise((resolve, reject) => {
handler(req, res, (result) => {
if (result instanceof Error) {
return reject(result)
}
return resolve(result)
})
})

1
packages/lib/index.ts Normal file
View File

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

29
packages/lib/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "@typebot.io/lib",
"version": "1.0.0",
"license": "AGPL-3.0-or-later",
"private": true,
"main": "./index.ts",
"types": "./index.ts",
"devDependencies": {
"@paralleldrive/cuid2": "2.2.0",
"@playwright/test": "1.31.2",
"@types/nodemailer": "6.4.7",
"aws-sdk": "2.1334.0",
"@typebot.io/prisma": "workspace:*",
"dotenv": "16.0.3",
"@typebot.io/schemas": "workspace:*",
"next": "13.2.4",
"nodemailer": "6.9.1",
"@typebot.io/tsconfig": "workspace:*",
"typescript": "4.9.5"
},
"peerDependencies": {
"aws-sdk": "2.1152.0",
"next": "13.0.0",
"nodemailer": "6.7.8"
},
"dependencies": {
"got": "12.6.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,51 @@
import { PlaywrightTestConfig } from '@playwright/test'
import path from 'path'
import fs from 'fs'
const builderLocalEnvPath = path.join(
__dirname,
'../../../apps/builder/.env.local'
)
const localViewerEnvPath = path.join(
__dirname,
'../../../apps/viewer/.env.local'
)
if (fs.existsSync(builderLocalEnvPath))
require('dotenv').config({
path: builderLocalEnvPath,
})
if (fs.existsSync(localViewerEnvPath))
require('dotenv').config({
path: localViewerEnvPath,
})
export const playwrightBaseConfig: PlaywrightTestConfig = {
globalSetup: require.resolve(path.join(__dirname, 'globalSetup')),
timeout: process.env.CI ? 50 * 1000 : 40 * 1000,
expect: {
timeout: process.env.CI ? 10 * 1000 : 5 * 1000,
},
retries: process.env.NO_RETRIES ? 0 : 1,
workers: process.env.CI ? 2 : 3,
reporter: [
[process.env.CI ? 'github' : 'list'],
['html', { outputFolder: 'src/test/reporters' }],
],
maxFailures: process.env.CI ? 10 : undefined,
webServer: process.env.CI
? {
command: 'pnpm run start',
timeout: 60_000,
reuseExistingServer: true,
}
: undefined,
outputDir: './src/test/results',
use: {
trace: 'on-first-retry',
video: 'retain-on-failure',
locale: 'en-US',
browserName: 'chromium',
viewport: { width: 1400, height: 1000 },
},
}

View File

@ -0,0 +1,231 @@
import {
Plan,
Prisma,
PrismaClient,
User,
Workspace,
WorkspaceRole,
} from '@typebot.io/prisma'
import { createId } from '@paralleldrive/cuid2'
import { Typebot, Webhook } from '@typebot.io/schemas'
import { readFileSync } from 'fs'
import { proWorkspaceId, userId } from './databaseSetup'
import {
parseTestTypebot,
parseTypebotToPublicTypebot,
} from './databaseHelpers'
const prisma = new PrismaClient()
type CreateFakeResultsProps = {
typebotId: string
count: number
customResultIdPrefix?: string
isChronological?: boolean
fakeStorage?: number
}
export const injectFakeResults = async ({
count,
customResultIdPrefix,
typebotId,
isChronological,
fakeStorage,
}: CreateFakeResultsProps) => {
const resultIdPrefix = customResultIdPrefix ?? createId()
await prisma.result.createMany({
data: [
...Array.from(Array(count)).map((_, idx) => {
const today = new Date()
const rand = Math.random()
return {
id: `${resultIdPrefix}-result${idx}`,
typebotId,
createdAt: isChronological
? new Date(
today.setTime(today.getTime() + 1000 * 60 * 60 * 24 * idx)
)
: new Date(),
isCompleted: rand > 0.5,
hasStarted: true,
variables: [],
} satisfies Prisma.ResultCreateManyInput
}),
],
})
return createAnswers({ fakeStorage, resultIdPrefix, count })
}
const createAnswers = ({
count,
resultIdPrefix,
fakeStorage,
}: { resultIdPrefix: string } & Pick<
CreateFakeResultsProps,
'fakeStorage' | 'count'
>) => {
return prisma.answer.createMany({
data: [
...Array.from(Array(count)).map((_, idx) => ({
resultId: `${resultIdPrefix}-result${idx}`,
content: `content${idx}`,
blockId: 'block1',
groupId: 'group1',
storageUsed: fakeStorage ? Math.round(fakeStorage / count) : null,
})),
],
})
}
export const importTypebotInDatabase = async (
path: string,
updates?: Partial<Typebot>
) => {
const typebot: Typebot = {
...JSON.parse(readFileSync(path).toString()),
workspaceId: proWorkspaceId,
...updates,
version: '3',
}
await prisma.typebot.create({
data: parseCreateTypebot(typebot),
})
return prisma.publicTypebot.create({
data: parseTypebotToPublicTypebot(
updates?.id ? `${updates?.id}-public` : 'publicBot',
typebot
),
})
}
export const deleteWorkspaces = async (workspaceIds: string[]) => {
await prisma.workspace.deleteMany({
where: { id: { in: workspaceIds } },
})
}
export const deleteTypebots = async (typebotIds: string[]) => {
await prisma.typebot.deleteMany({
where: { id: { in: typebotIds } },
})
}
export const deleteCredentials = async (credentialIds: string[]) => {
await prisma.credentials.deleteMany({
where: { id: { in: credentialIds } },
})
}
export const deleteWebhooks = async (webhookIds: string[]) => {
await prisma.webhook.deleteMany({
where: { id: { in: webhookIds } },
})
}
export const createWorkspaces = async (workspaces: Partial<Workspace>[]) => {
const workspaceIds = workspaces.map((workspace) => workspace.id ?? createId())
await prisma.workspace.createMany({
data: workspaces.map((workspace, index) => ({
id: workspaceIds[index],
name: 'Free workspace',
plan: Plan.FREE,
...workspace,
})),
})
await prisma.memberInWorkspace.createMany({
data: workspaces.map((_, index) => ({
userId,
workspaceId: workspaceIds[index],
role: WorkspaceRole.ADMIN,
})),
})
return workspaceIds
}
export const updateUser = (data: Partial<User>) =>
prisma.user.update({
data: {
...data,
onboardingCategories: data.onboardingCategories ?? [],
},
where: {
id: userId,
},
})
export const createWebhook = async (
typebotId: string,
webhookProps?: Partial<Webhook>
) => {
try {
await prisma.webhook.delete({ where: { id: 'webhook1' } })
} catch {}
return prisma.webhook.create({
data: {
method: 'GET',
typebotId,
id: 'webhook1',
...webhookProps,
queryParams: webhookProps?.queryParams ?? [],
headers: webhookProps?.headers ?? [],
},
})
}
export const createTypebots = async (partialTypebots: Partial<Typebot>[]) => {
const typebotsWithId = partialTypebots.map((typebot) => {
const typebotId = typebot.id ?? createId()
return {
...typebot,
id: typebotId,
publicId: typebot.publicId ?? typebotId + '-public',
}
})
await prisma.typebot.createMany({
data: typebotsWithId.map(parseTestTypebot).map(parseCreateTypebot),
})
return prisma.publicTypebot.createMany({
data: typebotsWithId.map((t) =>
parseTypebotToPublicTypebot(t.publicId, parseTestTypebot(t))
),
})
}
export const updateTypebot = async (
partialTypebot: Partial<Typebot> & { id: string }
) => {
await prisma.typebot.updateMany({
where: { id: partialTypebot.id },
data: parseUpdateTypebot(partialTypebot),
})
return prisma.publicTypebot.updateMany({
where: { typebotId: partialTypebot.id },
data: partialTypebot,
})
}
export const updateWorkspace = async (
id: string,
data: Prisma.WorkspaceUncheckedUpdateManyInput
) => {
await prisma.workspace.updateMany({
where: { id: proWorkspaceId },
data,
})
}
export const parseCreateTypebot = (typebot: Typebot) => ({
...typebot,
resultsTablePreferences:
typebot.resultsTablePreferences === null
? Prisma.DbNull
: typebot.resultsTablePreferences,
})
const parseUpdateTypebot = (typebot: Partial<Typebot>) => ({
...typebot,
resultsTablePreferences:
typebot.resultsTablePreferences === null
? Prisma.DbNull
: typebot.resultsTablePreferences,
})

View File

@ -0,0 +1,113 @@
import { createId } from '@paralleldrive/cuid2'
import {
Block,
defaultChoiceInputOptions,
defaultSettings,
defaultTheme,
InputBlockType,
ItemType,
PublicTypebot,
Typebot,
} from '@typebot.io/schemas'
import { isDefined } from '../utils'
import { proWorkspaceId } from './databaseSetup'
export const parseTestTypebot = (
partialTypebot: Partial<Typebot>
): Typebot => ({
id: createId(),
version: '3',
workspaceId: proWorkspaceId,
folderId: null,
name: 'My typebot',
theme: defaultTheme,
settings: defaultSettings,
publicId: null,
updatedAt: new Date(),
createdAt: new Date(),
customDomain: null,
icon: null,
isArchived: false,
isClosed: false,
resultsTablePreferences: null,
variables: [{ id: 'var1', name: 'var1' }],
...partialTypebot,
edges: [
{
id: 'edge1',
from: { groupId: 'group0', blockId: 'block0' },
to: { groupId: 'group1' },
},
],
groups: [
{
id: 'group0',
title: 'Group #0',
blocks: [
{
id: 'block0',
type: 'start',
groupId: 'group0',
label: 'Start',
outgoingEdgeId: 'edge1',
},
],
graphCoordinates: { x: 0, y: 0 },
},
...(partialTypebot.groups ?? []),
],
})
export const parseTypebotToPublicTypebot = (
id: string,
typebot: Typebot
): Omit<PublicTypebot, 'createdAt' | 'updatedAt'> => ({
id,
version: typebot.version,
groups: typebot.groups,
typebotId: typebot.id,
theme: typebot.theme,
settings: typebot.settings,
variables: typebot.variables,
edges: typebot.edges,
})
type Options = {
withGoButton?: boolean
}
export const parseDefaultGroupWithBlock = (
block: Partial<Block>,
options?: Options
): Pick<Typebot, 'groups'> => ({
groups: [
{
graphCoordinates: { x: 200, y: 200 },
id: 'group1',
blocks: [
options?.withGoButton
? {
id: 'block1',
groupId: 'group1',
type: InputBlockType.CHOICE,
items: [
{
id: 'item1',
blockId: 'block1',
type: ItemType.BUTTON,
content: 'Go',
},
],
options: defaultChoiceInputOptions,
}
: undefined,
{
id: 'block2',
groupId: 'group1',
...block,
} as Block,
].filter(isDefined) as Block[],
title: 'Group #1',
},
],
})

View File

@ -0,0 +1,168 @@
import {
GraphNavigation,
Plan,
PrismaClient,
WorkspaceRole,
} from '@typebot.io/prisma'
import { encrypt } from '../api'
const prisma = new PrismaClient()
export const apiToken = 'jirowjgrwGREHE'
export const userId = 'userId'
export const otherUserId = 'otherUserId'
export const proWorkspaceId = 'proWorkspace'
export const freeWorkspaceId = 'freeWorkspace'
export const starterWorkspaceId = 'starterWorkspace'
export const lifetimeWorkspaceId = 'lifetimeWorkspaceId'
export const customWorkspaceId = 'customWorkspaceId'
const setupWorkspaces = async () => {
await prisma.workspace.createMany({
data: [
{
id: freeWorkspaceId,
name: 'Free workspace',
plan: Plan.FREE,
},
{
id: starterWorkspaceId,
name: 'Starter workspace',
stripeId: 'cus_LnPDugJfa18N41',
plan: Plan.STARTER,
},
{
id: proWorkspaceId,
name: 'Pro workspace',
plan: Plan.PRO,
},
{
id: lifetimeWorkspaceId,
name: 'Lifetime workspace',
plan: Plan.LIFETIME,
},
{
id: customWorkspaceId,
name: 'Custom workspace',
plan: Plan.CUSTOM,
customChatsLimit: 100000,
customStorageLimit: 50,
customSeatsLimit: 20,
},
],
})
}
export const setupUsers = async () => {
await prisma.user.create({
data: {
id: userId,
email: 'user@email.com',
name: 'John Doe',
graphNavigation: GraphNavigation.TRACKPAD,
onboardingCategories: [],
apiTokens: {
createMany: {
data: [
{
name: 'Token 1',
token: apiToken,
createdAt: new Date(2022, 1, 1),
},
{
name: 'Github',
token: 'jirowjgrwGREHEgdrgithub',
createdAt: new Date(2022, 1, 2),
},
{
name: 'N8n',
token: 'jirowjgrwGREHrgwhrwn8n',
createdAt: new Date(2022, 1, 3),
},
],
},
},
},
})
await prisma.user.create({
data: {
id: otherUserId,
email: 'other-user@email.com',
name: 'James Doe',
onboardingCategories: [],
},
})
return prisma.memberInWorkspace.createMany({
data: [
{
role: WorkspaceRole.ADMIN,
userId,
workspaceId: freeWorkspaceId,
},
{
role: WorkspaceRole.ADMIN,
userId,
workspaceId: starterWorkspaceId,
},
{
role: WorkspaceRole.ADMIN,
userId,
workspaceId: proWorkspaceId,
},
{
role: WorkspaceRole.ADMIN,
userId,
workspaceId: lifetimeWorkspaceId,
},
{
role: WorkspaceRole.ADMIN,
userId,
workspaceId: customWorkspaceId,
},
],
})
}
const setupCredentials = () => {
const { encryptedData, iv } = encrypt({
expiry_date: 1642441058842,
access_token:
'ya29.A0ARrdaM--PV_87ebjywDJpXKb77NBFJl16meVUapYdfNv6W6ZzqqC47fNaPaRjbDbOIIcp6f49cMaX5ndK9TAFnKwlVqz3nrK9nLKqgyDIhYsIq47smcAIZkK56SWPx3X3DwAFqRu2UPojpd2upWwo-3uJrod',
// This token is linked to a test Google account (typebot.test.user@gmail.com)
refresh_token:
'1//039xWRt8YaYa3CgYIARAAGAMSNwF-L9Iru9FyuTrDSa7lkSceggPho83kJt2J29G69iEhT1C6XV1vmo6bQS9puL_R2t8FIwR3gek',
})
return prisma.credentials.createMany({
data: [
{
name: 'pro-user@email.com',
type: 'google sheets',
data: encryptedData,
workspaceId: proWorkspaceId,
iv,
},
],
})
}
export const setupDatabase = async () => {
await setupWorkspaces()
await setupUsers()
return setupCredentials()
}
export const teardownDatabase = async () => {
await prisma.workspace.deleteMany({
where: {
members: {
some: { userId: { in: [userId, otherUserId] } },
},
},
})
await prisma.user.deleteMany({
where: { id: { in: [userId, otherUserId] } },
})
return prisma.webhook.deleteMany()
}

View File

@ -0,0 +1,11 @@
import { FullConfig } from '@playwright/test'
import { setupDatabase, teardownDatabase } from './databaseSetup'
async function globalSetup(config: FullConfig) {
const { baseURL } = config.projects[0].use
if (!baseURL) throw new Error('baseURL is missing')
await teardownDatabase()
await setupDatabase()
}
export default globalSetup

View File

@ -0,0 +1,30 @@
import type { Page } from '@playwright/test'
export const mockSessionResponsesToOtherUser = async (page: Page) =>
page.route('/api/auth/session', (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({
status: 200,
body: '{"user":{"id":"otherUserId","name":"James Doe","email":"other-user@email.com","emailVerified":null,"image":"https://avatars.githubusercontent.com/u/16015833?v=4","stripeId":null,"graphNavigation": "TRACKPAD"}}',
})
}
return route.continue()
})
export const typebotViewer = (page: Page) =>
page.frameLocator('#typebot-iframe')
export const waitForSuccessfulPutRequest = (page: Page) =>
page.waitForResponse(
(resp) => resp.request().method() === 'PUT' && resp.status() === 200
)
export const waitForSuccessfulPostRequest = (page: Page) =>
page.waitForResponse(
(resp) => resp.request().method() === 'POST' && resp.status() === 200
)
export const waitForSuccessfulDeleteRequest = (page: Page) =>
page.waitForResponse(
(resp) => resp.request().method() === 'DELETE' && resp.status() === 200
)

221
packages/lib/pricing.ts Normal file
View File

@ -0,0 +1,221 @@
import type { Workspace } from '@typebot.io/prisma'
import { Plan } from '@typebot.io/prisma'
const infinity = -1
export const prices = {
[Plan.STARTER]: 39,
[Plan.PRO]: 89,
} as const
export const chatsLimit = {
[Plan.FREE]: { totalIncluded: 300 },
[Plan.STARTER]: {
totalIncluded: 2000,
increaseStep: {
amount: 500,
price: 10,
},
},
[Plan.PRO]: {
totalIncluded: 10000,
increaseStep: {
amount: 1000,
price: 10,
},
},
[Plan.CUSTOM]: {
totalIncluded: 2000,
increaseStep: {
amount: 500,
price: 10,
},
},
[Plan.OFFERED]: { totalIncluded: infinity },
[Plan.LIFETIME]: { totalIncluded: infinity },
[Plan.UNLIMITED]: { totalIncluded: infinity },
} as const
export const storageLimit = {
[Plan.FREE]: { totalIncluded: 0 },
[Plan.STARTER]: {
totalIncluded: 2,
increaseStep: {
amount: 1,
price: 2,
},
},
[Plan.PRO]: {
totalIncluded: 10,
increaseStep: {
amount: 1,
price: 2,
},
},
[Plan.CUSTOM]: {
totalIncluded: 2,
increaseStep: {
amount: 1,
price: 2,
},
},
[Plan.OFFERED]: { totalIncluded: 2 },
[Plan.LIFETIME]: { totalIncluded: 10 },
[Plan.UNLIMITED]: { totalIncluded: infinity },
} as const
export const seatsLimit = {
[Plan.FREE]: { totalIncluded: 1 },
[Plan.STARTER]: {
totalIncluded: 2,
},
[Plan.PRO]: {
totalIncluded: 5,
},
[Plan.CUSTOM]: {
totalIncluded: 2,
},
[Plan.OFFERED]: { totalIncluded: 2 },
[Plan.LIFETIME]: { totalIncluded: 8 },
[Plan.UNLIMITED]: { totalIncluded: infinity },
} as const
export const getChatsLimit = ({
plan,
additionalChatsIndex,
customChatsLimit,
}: Pick<Workspace, 'additionalChatsIndex' | 'plan' | 'customChatsLimit'>) => {
if (customChatsLimit) return customChatsLimit
const { totalIncluded } = chatsLimit[plan]
const increaseStep =
plan === Plan.STARTER || plan === Plan.PRO
? chatsLimit[plan].increaseStep
: { amount: 0 }
if (totalIncluded === infinity) return infinity
return totalIncluded + increaseStep.amount * additionalChatsIndex
}
export const getStorageLimit = ({
plan,
additionalStorageIndex,
customStorageLimit,
}: Pick<
Workspace,
'additionalStorageIndex' | 'plan' | 'customStorageLimit'
>) => {
if (customStorageLimit) return customStorageLimit
const { totalIncluded } = storageLimit[plan]
const increaseStep =
plan === Plan.STARTER || plan === Plan.PRO
? storageLimit[plan].increaseStep
: { amount: 0 }
return totalIncluded + increaseStep.amount * additionalStorageIndex
}
export const getSeatsLimit = ({
plan,
customSeatsLimit,
}: Pick<Workspace, 'plan' | 'customSeatsLimit'>) => {
if (customSeatsLimit) return customSeatsLimit
return seatsLimit[plan].totalIncluded
}
export const isSeatsLimitReached = ({
existingMembersCount,
existingInvitationsCount,
plan,
customSeatsLimit,
}: { existingMembersCount: number; existingInvitationsCount: number } & Pick<
Workspace,
'plan' | 'customSeatsLimit'
>) => {
const seatsLimit = getSeatsLimit({ plan, customSeatsLimit })
return (
seatsLimit !== infinity &&
seatsLimit <= existingMembersCount + existingInvitationsCount
)
}
export const computePrice = (
plan: Plan,
selectedTotalChatsIndex: number,
selectedTotalStorageIndex: number
) => {
if (plan !== Plan.STARTER && plan !== Plan.PRO) return
const {
increaseStep: { price: chatsPrice },
} = chatsLimit[plan]
const {
increaseStep: { price: storagePrice },
} = storageLimit[plan]
return (
prices[plan] +
selectedTotalChatsIndex * chatsPrice +
selectedTotalStorageIndex * storagePrice
)
}
const europeanUnionCountryCodes = [
'AT',
'BE',
'BG',
'CY',
'CZ',
'DE',
'DK',
'EE',
'ES',
'FI',
'FR',
'GR',
'HR',
'HU',
'IE',
'IT',
'LT',
'LU',
'LV',
'MT',
'NL',
'PL',
'PT',
'RO',
'SE',
'SI',
'SK',
]
const europeanUnionExclusiveLanguageCodes = [
'fr',
'de',
'it',
'el',
'pl',
'fi',
'nl',
'hr',
'cs',
'hu',
'ro',
'sl',
'sv',
'bg',
]
export const guessIfUserIsEuropean = () =>
window.navigator.languages.some((language) => {
const [languageCode, countryCode] = language.split('-')
return countryCode
? europeanUnionCountryCodes.includes(countryCode)
: europeanUnionExclusiveLanguageCodes.includes(languageCode)
})
export const formatPrice = (price: number, currency?: 'eur' | 'usd') => {
const isEuropean = guessIfUserIsEuropean()
const formatter = new Intl.NumberFormat(isEuropean ? 'fr-FR' : 'en-US', {
style: 'currency',
currency: currency?.toUpperCase() ?? (isEuropean ? 'EUR' : 'USD'),
maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
})
return formatter.format(price)
}

265
packages/lib/results.ts Normal file
View File

@ -0,0 +1,265 @@
import {
Group,
Variable,
InputBlock,
ResultHeaderCell,
Answer,
VariableWithValue,
Typebot,
ResultWithAnswers,
InputBlockType,
ResultInSession,
} from '@typebot.io/schemas'
import {
isInputBlock,
isDefined,
byId,
isNotEmpty,
parseGroupTitle,
} from './utils'
export const parseResultHeader = (
typebot: Pick<Typebot, 'groups' | 'variables'>,
linkedTypebots: Pick<Typebot, 'groups' | 'variables'>[] | undefined,
results?: ResultWithAnswers[]
): ResultHeaderCell[] => {
const parsedGroups = [
...typebot.groups,
...(linkedTypebots ?? []).flatMap((linkedTypebot) => linkedTypebot.groups),
]
const parsedVariables = [
...typebot.variables,
...(linkedTypebots ?? []).flatMap(
(linkedTypebot) => linkedTypebot.variables
),
]
const inputsResultHeader = parseInputsResultHeader({
groups: parsedGroups,
variables: parsedVariables,
})
return [
{ label: 'Submitted at', id: 'date' },
...inputsResultHeader,
...parseVariablesHeaders(parsedVariables, inputsResultHeader),
...parseResultsFromPreviousBotVersions(results ?? [], inputsResultHeader),
]
}
type ResultHeaderCellWithBlock = Omit<ResultHeaderCell, 'blocks'> & {
blocks: NonNullable<ResultHeaderCell['blocks']>
}
const parseInputsResultHeader = ({
groups,
variables,
}: {
groups: Group[]
variables: Variable[]
}): ResultHeaderCellWithBlock[] =>
(
groups
.flatMap((group) =>
group.blocks.map((block) => ({
...block,
groupTitle: parseGroupTitle(group.title),
}))
)
.filter((block) => isInputBlock(block)) as (InputBlock & {
groupTitle: string
})[]
).reduce<ResultHeaderCellWithBlock[]>((existingHeaders, inputBlock) => {
if (
existingHeaders.some(
(existingHeader) =>
inputBlock.options.variableId &&
existingHeader.variableIds?.includes(inputBlock.options.variableId)
)
)
return existingHeaders
const matchedVariableName =
inputBlock.options.variableId &&
variables.find(byId(inputBlock.options.variableId))?.name
let label = matchedVariableName ?? inputBlock.groupTitle
const existingHeader = existingHeaders.find((h) => h.label === label)
if (existingHeader) {
if (
existingHeader.blocks?.some(
(block) => block.groupId === inputBlock.groupId
) ||
existingHeader.label.includes('Untitled')
) {
const totalPrevious = existingHeaders.filter((h) =>
h.label.includes(label)
).length
const newHeaderCell: ResultHeaderCellWithBlock = {
id: inputBlock.id,
label: label + ` (${totalPrevious})`,
blocks: [
{
id: inputBlock.id,
groupId: inputBlock.groupId,
},
],
blockType: inputBlock.type,
variableIds: inputBlock.options.variableId
? [inputBlock.options.variableId]
: undefined,
}
return [...existingHeaders, newHeaderCell]
}
const updatedHeaderCell: ResultHeaderCellWithBlock = {
...existingHeader,
variableIds:
existingHeader.variableIds && inputBlock.options.variableId
? existingHeader.variableIds.concat([inputBlock.options.variableId])
: undefined,
blocks: existingHeader.blocks.concat({
id: inputBlock.id,
groupId: inputBlock.groupId,
}),
}
return [
...existingHeaders.filter(
(existingHeader) => existingHeader.label !== label
),
updatedHeaderCell,
]
}
const newHeaderCell: ResultHeaderCellWithBlock = {
id: inputBlock.id,
label,
blocks: [
{
id: inputBlock.id,
groupId: inputBlock.groupId,
},
],
blockType: inputBlock.type,
variableIds: inputBlock.options.variableId
? [inputBlock.options.variableId]
: undefined,
}
return [...existingHeaders, newHeaderCell]
}, [])
const parseVariablesHeaders = (
variables: Variable[],
existingInputResultHeaders: ResultHeaderCell[]
) =>
variables.reduce<ResultHeaderCell[]>((existingHeaders, variable) => {
if (
existingInputResultHeaders.some((existingInputResultHeader) =>
existingInputResultHeader.variableIds?.includes(variable.id)
)
)
return existingHeaders
const headerCellWithSameLabel = existingHeaders.find(
(existingHeader) => existingHeader.label === variable.name
)
if (headerCellWithSameLabel) {
const updatedHeaderCell: ResultHeaderCell = {
...headerCellWithSameLabel,
variableIds: headerCellWithSameLabel.variableIds?.concat([variable.id]),
}
return [
...existingHeaders.filter((h) => h.label !== variable.name),
updatedHeaderCell,
]
}
const newHeaderCell: ResultHeaderCell = {
id: variable.id,
label: variable.name,
variableIds: [variable.id],
}
return [...existingHeaders, newHeaderCell]
}, [])
const parseResultsFromPreviousBotVersions = (
results: ResultWithAnswers[],
existingInputResultHeaders: ResultHeaderCell[]
): ResultHeaderCell[] =>
results
.flatMap((result) => result.answers)
.filter(
(answer) =>
!answer.variableId &&
existingInputResultHeaders.every(
(header) => header.id !== answer.blockId
) &&
isNotEmpty(answer.content)
)
.reduce<ResultHeaderCell[]>((existingHeaders, answer) => {
if (
existingHeaders.some(
(existingHeader) => existingHeader.id === answer.blockId
)
)
return existingHeaders
return [
...existingHeaders,
{
id: answer.blockId,
label: `${answer.blockId} (deleted block)`,
blocks: [
{
id: answer.blockId,
groupId: answer.groupId,
},
],
blockType: InputBlockType.TEXT,
},
]
}, [])
export const parseAnswers =
(
typebot: Pick<Typebot, 'groups' | 'variables'>,
linkedTypebots: Pick<Typebot, 'groups' | 'variables'>[] | undefined
) =>
({
createdAt,
answers,
variables: resultVariables,
}: Omit<ResultInSession, 'hasStarted'> & { createdAt?: Date | string }): {
[key: string]: string
} => {
const header = parseResultHeader(typebot, linkedTypebots)
return {
submittedAt: !createdAt
? new Date().toISOString()
: typeof createdAt === 'string'
? createdAt
: createdAt.toISOString(),
...[...answers, ...resultVariables].reduce<{
[key: string]: string
}>((o, answerOrVariable) => {
const isVariable = !('blockId' in answerOrVariable)
if (isVariable) {
const variable = answerOrVariable as VariableWithValue
if (variable.value === null) return o
return { ...o, [variable.name]: variable.value.toString() }
}
const answer = answerOrVariable as Answer
const key = answer.variableId
? header.find(
(cell) =>
answer.variableId &&
cell.variableIds?.includes(answer.variableId)
)?.label
: header.find((cell) =>
cell.blocks?.some((block) => block.id === answer.blockId)
)?.label
if (!key) return o
if (isDefined(o[key])) return o
return {
...o,
[key]: answer.content.toString(),
}
}, {}),
}
}

View File

@ -0,0 +1,29 @@
import got from 'got'
import { TelemetryEvent } from '@typebot.io/schemas/features/telemetry'
import { isEmpty, isNotEmpty } from '../utils'
export const sendTelemetryEvents = async (events: TelemetryEvent[]) => {
if (isEmpty(process.env.TELEMETRY_WEBHOOK_URL))
return { message: 'Telemetry not enabled' }
try {
await got.post(process.env.TELEMETRY_WEBHOOK_URL, {
json: { events },
headers: {
authorization: isNotEmpty(process.env.TELEMETRY_WEBHOOK_BEARER_TOKEN)
? `Bearer ${process.env.TELEMETRY_WEBHOOK_BEARER_TOKEN}`
: undefined,
},
})
} catch (err) {
console.error('Failed to send event', err)
return {
message: 'Failed to send event',
error: err instanceof Error ? err.message : 'Unknown error',
}
}
return {
message: 'Event sent',
}
}

View File

@ -0,0 +1,5 @@
{
"extends": "@typebot.io/tsconfig/base.json",
"include": ["**/*.ts"],
"exclude": ["node_modules"]
}

310
packages/lib/utils.ts Normal file
View File

@ -0,0 +1,310 @@
import type {
BubbleBlock,
ChoiceInputBlock,
ConditionBlock,
InputBlock,
IntegrationBlock,
LogicBlock,
Block,
TextInputBlock,
TextBubbleBlock,
WebhookBlock,
BlockType,
ImageBubbleBlock,
VideoBubbleBlock,
BlockWithOptionsType,
} from '@typebot.io/schemas'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/enums'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/enums'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/enums'
import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integrations/enums'
export const sendRequest = async <ResponseData>(
params:
| {
url: string
method: string
body?: Record<string, unknown> | FormData
}
| string
): Promise<{ data?: ResponseData; error?: Error }> => {
try {
const url = typeof params === 'string' ? params : params.url
const response = await fetch(url, {
method: typeof params === 'string' ? 'GET' : params.method,
mode: 'cors',
headers:
typeof params !== 'string' && isDefined(params.body)
? {
'Content-Type': 'application/json',
}
: undefined,
body:
typeof params !== 'string' && isDefined(params.body)
? JSON.stringify(params.body)
: undefined,
})
const data = await response.json()
if (!response.ok) throw 'error' in data ? data.error : data
return { data }
} catch (e) {
console.error(e)
return { error: e as Error }
}
}
export const isDefined = <T>(
value: T | undefined | null
): value is NonNullable<T> => value !== undefined && value !== null
export const isNotDefined = <T>(
value: T | undefined | null
): value is undefined | null => value === undefined || value === null
export const isEmpty = (value: string | undefined | null): value is undefined =>
value === undefined || value === null || value === ''
export const isNotEmpty = (value: string | undefined | null): value is string =>
value !== undefined && value !== null && value !== ''
export const isInputBlock = (block: Block): block is InputBlock =>
(Object.values(InputBlockType) as string[]).includes(block.type)
export const isBubbleBlock = (block: Block): block is BubbleBlock =>
(Object.values(BubbleBlockType) as string[]).includes(block.type)
export const isLogicBlock = (block: Block): block is LogicBlock =>
(Object.values(LogicBlockType) as string[]).includes(block.type)
export const isTextBubbleBlock = (block: Block): block is TextBubbleBlock =>
block.type === BubbleBlockType.TEXT
export const isMediaBubbleBlock = (
block: Block
): block is ImageBubbleBlock | VideoBubbleBlock =>
block.type === BubbleBlockType.IMAGE || block.type === BubbleBlockType.VIDEO
export const isTextInputBlock = (block: Block): block is TextInputBlock =>
block.type === InputBlockType.TEXT
export const isChoiceInput = (block: Block): block is ChoiceInputBlock =>
block.type === InputBlockType.CHOICE
export const isSingleChoiceInput = (block: Block): block is ChoiceInputBlock =>
block.type === InputBlockType.CHOICE &&
'options' in block &&
!block.options.isMultipleChoice
export const isConditionBlock = (block: Block): block is ConditionBlock =>
block.type === LogicBlockType.CONDITION
export const isIntegrationBlock = (block: Block): block is IntegrationBlock =>
(Object.values(IntegrationBlockType) as string[]).includes(block.type)
export const isWebhookBlock = (block: Block): block is WebhookBlock =>
[
IntegrationBlockType.WEBHOOK,
IntegrationBlockType.PABBLY_CONNECT,
IntegrationBlockType.ZAPIER,
IntegrationBlockType.MAKE_COM,
].includes(block.type as IntegrationBlockType)
export const isBubbleBlockType = (type: BlockType): type is BubbleBlockType =>
(Object.values(BubbleBlockType) as string[]).includes(type)
export const blockTypeHasOption = (
type: BlockType
): type is BlockWithOptionsType =>
(Object.values(InputBlockType) as string[])
.concat(Object.values(LogicBlockType))
.concat(Object.values(IntegrationBlockType))
.includes(type)
export const blockTypeHasWebhook = (
type: BlockType
): type is IntegrationBlockType.WEBHOOK =>
Object.values([
IntegrationBlockType.WEBHOOK,
IntegrationBlockType.ZAPIER,
IntegrationBlockType.MAKE_COM,
IntegrationBlockType.PABBLY_CONNECT,
] as string[]).includes(type)
export const blockTypeHasItems = (
type: BlockType
): type is LogicBlockType.CONDITION | InputBlockType.CHOICE =>
type === LogicBlockType.CONDITION || type === InputBlockType.CHOICE
export const blockHasItems = (
block: Block
): block is ConditionBlock | ChoiceInputBlock =>
'items' in block && isDefined(block.items)
export const byId = (id?: string) => (obj: { id: string }) => obj.id === id
export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
interface Omit {
// eslint-disable-next-line @typescript-eslint/ban-types
<T extends object, K extends [...(keyof T)[]]>(obj: T, ...keys: K): {
[K2 in Exclude<keyof T, K[number]>]: T[K2]
}
}
export const omit: Omit = (obj, ...keys) => {
const ret = {} as {
[K in keyof typeof obj]: typeof obj[K]
}
let key: keyof typeof obj
for (key in obj) {
if (!keys.includes(key)) {
ret[key] = obj[key]
}
}
return ret
}
export const sanitizeUrl = (url: string): string =>
url.startsWith('http') ||
url.startsWith('mailto:') ||
url.startsWith('tel:') ||
url.startsWith('sms:')
? url
: `https://${url}`
export const toTitleCase = (str: string) =>
str.replace(
/\w\S*/g,
(txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()
)
export const generateId = (idDesiredLength: number): string => {
const getRandomCharFromAlphabet = (alphabet: string): string => {
return alphabet.charAt(Math.floor(Math.random() * alphabet.length))
}
return Array.from({ length: idDesiredLength })
.map(() => {
return getRandomCharFromAlphabet(
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
)
})
.join('')
}
type UploadFileProps = {
basePath?: string
files: {
file: File
path: string
}[]
onUploadProgress?: (percent: number) => void
}
type UrlList = (string | null)[]
export const uploadFiles = async ({
basePath = '/api',
files,
onUploadProgress,
}: UploadFileProps): Promise<UrlList> => {
const urls = []
let i = 0
for (const { file, path } of files) {
onUploadProgress && onUploadProgress((i / files.length) * 100)
i += 1
const { data } = await sendRequest<{
presignedUrl: { url: string; fields: any }
hasReachedStorageLimit: boolean
}>(
`${basePath}/storage/upload-url?filePath=${encodeURIComponent(
path
)}&fileType=${file.type}`
)
if (!data?.presignedUrl) continue
const { url, fields } = data.presignedUrl
if (data.hasReachedStorageLimit) urls.push(null)
else {
const formData = new FormData()
Object.entries({ ...fields, file }).forEach(([key, value]) => {
formData.append(key, value as string | Blob)
})
const upload = await fetch(url, {
method: 'POST',
body: formData,
})
if (!upload.ok) continue
urls.push(`${url.split('?')[0]}/${path}`)
}
}
return urls
}
declare const window: any
export const env = (key = ''): string | undefined => {
if (typeof window === 'undefined')
return isEmpty(process.env['NEXT_PUBLIC_' + key])
? undefined
: (process.env['NEXT_PUBLIC_' + key] as string)
if (typeof window !== 'undefined' && window.__env)
return isEmpty(window.__env[key]) ? undefined : window.__env[key]
}
export const hasValue = (
value: string | undefined | null
): value is NonNullable<string> =>
value !== undefined &&
value !== null &&
value !== '' &&
value !== 'undefined' &&
value !== 'null'
export const getViewerUrl = (props?: {
returnAll?: boolean
}): string | undefined =>
props?.returnAll ? env('VIEWER_URL') : env('VIEWER_URL')?.split(',')[0]
export const parseNumberWithCommas = (num: number) =>
num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
export const injectCustomHeadCode = (customHeadCode: string) => {
const headCodes = customHeadCode.split('</noscript>')
headCodes.forEach((headCode) => {
const [codeToInject, noScriptContentToInject] = headCode.split('<noscript>')
const fragment = document
.createRange()
.createContextualFragment(codeToInject)
document.head.append(fragment)
if (isNotDefined(noScriptContentToInject)) return
const noScriptElement = document.createElement('noscript')
const noScriptContentFragment = document
.createRange()
.createContextualFragment(noScriptContentToInject)
noScriptElement.append(noScriptContentFragment)
document.head.append(noScriptElement)
})
}
export const getAtPath = <T>(obj: T, path: string): unknown => {
if (isNotDefined(obj)) return undefined
const pathParts = path.split('.')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let current: any = obj
for (const part of pathParts) {
if (current === undefined) {
return undefined
}
current = current[part]
}
return current
}
export const parseGroupTitle = (title: string) =>
isEmpty(title) ? 'Untitled' : title