♻️ Re-organize workspace folders
This commit is contained in:
4
packages/lib/.eslintrc.js
Normal file
4
packages/lib/.eslintrc.js
Normal file
@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['custom'],
|
||||
}
|
36
packages/lib/api/encryption.ts
Normal file
36
packages/lib/api/encryption.ts
Normal 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()
|
||||
)
|
||||
}
|
3
packages/lib/api/index.ts
Normal file
3
packages/lib/api/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './utils'
|
||||
export * from './storage'
|
||||
export * from './encryption'
|
53
packages/lib/api/storage.ts
Normal file
53
packages/lib/api/storage.ts
Normal 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
38
packages/lib/api/utils.ts
Normal 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
1
packages/lib/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './utils'
|
29
packages/lib/package.json
Normal file
29
packages/lib/package.json
Normal 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"
|
||||
}
|
||||
}
|
1484
packages/lib/phoneCountries.ts
Normal file
1484
packages/lib/phoneCountries.ts
Normal file
File diff suppressed because it is too large
Load Diff
51
packages/lib/playwright/baseConfig.ts
Normal file
51
packages/lib/playwright/baseConfig.ts
Normal 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 },
|
||||
},
|
||||
}
|
231
packages/lib/playwright/databaseActions.ts
Normal file
231
packages/lib/playwright/databaseActions.ts
Normal 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,
|
||||
})
|
113
packages/lib/playwright/databaseHelpers.ts
Normal file
113
packages/lib/playwright/databaseHelpers.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
})
|
168
packages/lib/playwright/databaseSetup.ts
Normal file
168
packages/lib/playwright/databaseSetup.ts
Normal 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()
|
||||
}
|
11
packages/lib/playwright/globalSetup.ts
Normal file
11
packages/lib/playwright/globalSetup.ts
Normal 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
|
30
packages/lib/playwright/testHelpers.ts
Normal file
30
packages/lib/playwright/testHelpers.ts
Normal 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
221
packages/lib/pricing.ts
Normal 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
265
packages/lib/results.ts
Normal 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(),
|
||||
}
|
||||
}, {}),
|
||||
}
|
||||
}
|
29
packages/lib/telemetry/sendTelemetryEvent.ts
Normal file
29
packages/lib/telemetry/sendTelemetryEvent.ts
Normal 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',
|
||||
}
|
||||
}
|
5
packages/lib/tsconfig.json
Normal file
5
packages/lib/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@typebot.io/tsconfig/base.json",
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
310
packages/lib/utils.ts
Normal file
310
packages/lib/utils.ts
Normal 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
|
Reference in New Issue
Block a user