diff --git a/apps/builder/components/shared/Graph/Nodes/BlockNode/SettingsPopoverContent/bodies/FileInputSettings.tsx b/apps/builder/components/shared/Graph/Nodes/BlockNode/SettingsPopoverContent/bodies/FileInputSettings.tsx index ca9574d7f..fae604245 100644 --- a/apps/builder/components/shared/Graph/Nodes/BlockNode/SettingsPopoverContent/bodies/FileInputSettings.tsx +++ b/apps/builder/components/shared/Graph/Nodes/BlockNode/SettingsPopoverContent/bodies/FileInputSettings.tsx @@ -1,5 +1,6 @@ import { FormLabel, Stack } from '@chakra-ui/react' import { CodeEditor } from 'components/shared/CodeEditor' +import { SmartNumberInput } from 'components/shared/SmartNumberInput' import { SwitchWithLabel } from 'components/shared/SwitchWithLabel' import { Input } from 'components/shared/Textbox' import { VariableSearchInput } from 'components/shared/VariableSearchInput' @@ -20,7 +21,8 @@ export const FileInputSettings = ({ options, onOptionsChange }: Props) => { onOptionsChange({ ...options, isMultipleAllowed }) const handleVariableChange = (variable?: Variable) => onOptionsChange({ ...options, variableId: variable?.id }) - + const handleSizeLimitChange = (sizeLimit?: number) => + onOptionsChange({ ...options, sizeLimit }) return ( { initialValue={options.isMultipleAllowed} onCheckChange={handleLongChange} /> + + + Size limit (MB): + + + Placeholder: { await page.click('text="Allow multiple files?"') await page.fill('div[contenteditable=true]', 'Upload now!!') await page.fill('[value="Upload"]', 'Go') + await page.fill('input[value="10"]', '20') await page.click('text="Restart"') await expect(typebotViewer(page).locator(`text="Upload now!!"`)).toBeVisible() await typebotViewer(page) diff --git a/apps/viewer/layouts/TypebotPage.tsx b/apps/viewer/layouts/TypebotPage.tsx index 3e02ba7c5..07d0c0b7b 100644 --- a/apps/viewer/layouts/TypebotPage.tsx +++ b/apps/viewer/layouts/TypebotPage.tsx @@ -35,6 +35,7 @@ export const TypebotPage = ({ const [variableUpdateQueue, setVariableUpdateQueue] = useState< VariableWithValue[][] >([]) + const [chatStarted, setChatStarted] = useState(false) useEffect(() => { setShowTypebot(true) @@ -94,10 +95,16 @@ export const TypebotPage = ({ if (error) setError(error) } - const handleNewAnswer = async (answer: Answer) => { + const handleNewAnswer = async ( + answer: Answer & { uploadedFiles: boolean } + ) => { if (!resultId) return setError(new Error('Result was not created')) const { error } = await upsertAnswer({ ...answer, resultId }) if (error) setError(error) + if (chatStarted) return + updateResult(resultId, { + hasStarted: true, + }).then(({ error }) => (error ? setError(error) : setChatStarted(true))) } const handleCompleted = async () => { diff --git a/apps/viewer/pages/api/storage/upload-url.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/storage/upload-url.ts similarity index 50% rename from apps/viewer/pages/api/storage/upload-url.ts rename to apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/storage/upload-url.ts index a99cfc262..3f433d00b 100644 --- a/apps/viewer/pages/api/storage/upload-url.ts +++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/storage/upload-url.ts @@ -1,6 +1,8 @@ import { withSentry } from '@sentry/nextjs' +import prisma from 'libs/prisma' +import { InputBlockType, PublicTypebot } from 'models' import { NextApiRequest, NextApiResponse } from 'next' -import { badRequest, generatePresignedUrl, methodNotAllowed } from 'utils' +import { badRequest, generatePresignedUrl, methodNotAllowed, byId } from 'utils' const handler = async ( req: NextApiRequest, @@ -19,8 +21,25 @@ const handler = async ( ) const filePath = req.query.filePath as string | undefined const fileType = req.query.fileType as string | undefined + const typebotId = req.query.typebotId as string + const blockId = req.query.blockId as string if (!filePath || !fileType) return badRequest(res) - const presignedUrl = generatePresignedUrl({ fileType, filePath }) + const typebot = (await prisma.publicTypebot.findFirst({ + where: { typebotId }, + })) as unknown as PublicTypebot + const fileUploadBlock = typebot.groups + .flatMap((g) => g.blocks) + .find(byId(blockId)) + if (fileUploadBlock?.type !== InputBlockType.FILE) return badRequest(res) + const sizeLimit = fileUploadBlock.options.sizeLimit + ? Math.min(fileUploadBlock.options.sizeLimit, 500) + : 10 + + const presignedUrl = generatePresignedUrl({ + fileType, + filePath, + sizeLimit: sizeLimit * 1024 * 1024, + }) return res.status(200).send({ presignedUrl }) } diff --git a/apps/viewer/pages/api/typebots/[typebotId]/results.ts b/apps/viewer/pages/api/typebots/[typebotId]/results.ts index 9ca4c4ebd..dfab17fd3 100644 --- a/apps/viewer/pages/api/typebots/[typebotId]/results.ts +++ b/apps/viewer/pages/api/typebots/[typebotId]/results.ts @@ -26,7 +26,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (req.method === 'POST') { const typebotId = req.query.typebotId as string const result = await prisma.result.create({ - data: { typebotId, isCompleted: false }, + data: { + typebotId, + isCompleted: false, + }, }) return res.send(result) } diff --git a/apps/viewer/pages/api/typebots/[typebotId]/results/[resultId]/answers.ts b/apps/viewer/pages/api/typebots/[typebotId]/results/[resultId]/answers.ts index 72be95fc4..e6f0647ef 100644 --- a/apps/viewer/pages/api/typebots/[typebotId]/results/[resultId]/answers.ts +++ b/apps/viewer/pages/api/typebots/[typebotId]/results/[resultId]/answers.ts @@ -1,14 +1,25 @@ import { withSentry } from '@sentry/nextjs' import { Answer } from 'db' +import { got } from 'got' import prisma from 'libs/prisma' import { NextApiRequest, NextApiResponse } from 'next' -import { methodNotAllowed } from 'utils' +import { isNotDefined, methodNotAllowed } from 'utils' const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (req.method === 'PUT') { - const answer = ( + const { uploadedFiles, ...answer } = ( typeof req.body === 'string' ? JSON.parse(req.body) : req.body - ) as Answer + ) as Answer & { uploadedFiles?: boolean } + let storageUsed = 0 + if (uploadedFiles && answer.content.includes('http')) { + const fileUrls = answer.content.split(', ') + for (const url of fileUrls) { + const { headers } = await got(url) + const size = headers['content-length'] + if (isNotDefined(size)) return + storageUsed += parseInt(size, 10) + } + } const result = await prisma.answer.upsert({ where: { resultId_blockId_groupId: { @@ -17,8 +28,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { blockId: answer.blockId, }, }, - create: answer, - update: answer, + create: { ...answer, storageUsed: storageUsed > 0 ? storageUsed : null }, + update: { ...answer, storageUsed: storageUsed > 0 ? storageUsed : null }, }) return res.send(result) } diff --git a/apps/viewer/services/answer.ts b/apps/viewer/services/answer.ts index d5740ff1b..914aed933 100644 --- a/apps/viewer/services/answer.ts +++ b/apps/viewer/services/answer.ts @@ -1,7 +1,9 @@ import { Answer } from 'models' import { sendRequest } from 'utils' -export const upsertAnswer = async (answer: Answer & { resultId: string }) => +export const upsertAnswer = async ( + answer: Answer & { resultId: string } & { uploadedFiles?: boolean } +) => sendRequest({ url: `/api/typebots/t/results/r/answers`, method: 'PUT', diff --git a/packages/bot-engine/src/components/ChatGroup/ChatBlock/InputChatBlock.tsx b/packages/bot-engine/src/components/ChatGroup/ChatBlock/InputChatBlock.tsx index 8f0835092..194188bac 100644 --- a/packages/bot-engine/src/components/ChatGroup/ChatBlock/InputChatBlock.tsx +++ b/packages/bot-engine/src/components/ChatGroup/ChatBlock/InputChatBlock.tsx @@ -53,6 +53,7 @@ export const InputChatBlock = ({ groupId: block.groupId, content: value, variableId: variableId ?? null, + uploadedFiles: block.type === InputBlockType.FILE, }) if (!isEditting) onTransitionEnd({ label, value, itemId }, isRetry) setIsEditting(false) diff --git a/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/FileUploadForm.tsx b/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/FileUploadForm.tsx index ebff36346..95c1690af 100644 --- a/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/FileUploadForm.tsx +++ b/packages/bot-engine/src/components/ChatGroup/ChatBlock/inputs/FileUploadForm.tsx @@ -11,15 +11,17 @@ type Props = { onSubmit: (url: InputSubmitContent) => void } -const tenMB = 10 * 1024 * 1024 export const FileUploadForm = ({ block: { id, - options: { isMultipleAllowed, labels }, + options: { isMultipleAllowed, labels, sizeLimit }, }, onSubmit, }: Props) => { - const { isPreview } = useTypebot() + const { + isPreview, + typebot: { typebotId }, + } = useTypebot() const { resultId } = useAnswers() const [selectedFiles, setSelectedFiles] = useState([]) const [isUploading, setIsUploading] = useState(false) @@ -35,8 +37,8 @@ export const FileUploadForm = ({ const onNewFiles = (files: FileList) => { setErrorMessage(undefined) const newFiles = Array.from(files) - if (newFiles.some((file) => file.size > tenMB)) - return setErrorMessage('A file is larger than 10MB') + if (newFiles.some((file) => file.size > (sizeLimit ?? 10) * 1024 * 1024)) + return setErrorMessage(`A file is larger than ${sizeLimit ?? 10}MB`) if (!isMultipleAllowed && files) return startSingleFileUpload(newFiles[0]) setSelectedFiles([...selectedFiles, ...newFiles]) } @@ -55,6 +57,7 @@ export const FileUploadForm = ({ }) setIsUploading(true) const urls = await uploadFiles({ + basePath: `/api/typebots/${typebotId}/blocks/${id}`, files: [ { file, @@ -76,6 +79,7 @@ export const FileUploadForm = ({ }) setIsUploading(true) const urls = await uploadFiles({ + basePath: `/api/typebots/${typebotId}/blocks/${id}`, files: files.map((file) => ({ file: file, path: `public/results/${resultId}/${id}/${file.name}`, diff --git a/packages/bot-engine/src/components/TypebotViewer.tsx b/packages/bot-engine/src/components/TypebotViewer.tsx index 6a65a4bc6..fa201b451 100644 --- a/packages/bot-engine/src/components/TypebotViewer.tsx +++ b/packages/bot-engine/src/components/TypebotViewer.tsx @@ -33,7 +33,7 @@ export type TypebotViewerProps = { startGroupId?: string isLoading?: boolean onNewGroupVisible?: (edge: Edge) => void - onNewAnswer?: (answer: Answer) => Promise + onNewAnswer?: (answer: Answer & { uploadedFiles: boolean }) => Promise onNewLog?: (log: Omit) => void onCompleted?: () => void onVariablesUpdated?: (variables: VariableWithValue[]) => void @@ -64,7 +64,8 @@ export const TypebotViewer = ({ const handleNewGroupVisible = (edge: Edge) => onNewGroupVisible && onNewGroupVisible(edge) - const handleNewAnswer = (answer: Answer) => onNewAnswer && onNewAnswer(answer) + const handleNewAnswer = (answer: Answer & { uploadedFiles: boolean }) => + onNewAnswer && onNewAnswer(answer) const handleNewLog = (log: Omit) => onNewLog && onNewLog(log) diff --git a/packages/bot-engine/src/contexts/AnswersContext.tsx b/packages/bot-engine/src/contexts/AnswersContext.tsx index 61af7729d..05612e1b7 100644 --- a/packages/bot-engine/src/contexts/AnswersContext.tsx +++ b/packages/bot-engine/src/contexts/AnswersContext.tsx @@ -4,7 +4,9 @@ import React, { createContext, ReactNode, useContext, useState } from 'react' const answersContext = createContext<{ resultId?: string resultValues: ResultValues - addAnswer: (answer: Answer) => Promise | undefined + addAnswer: ( + answer: Answer & { uploadedFiles: boolean } + ) => Promise | undefined updateVariables: (variables: VariableWithValue[]) => void // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore @@ -17,7 +19,9 @@ export const AnswersContext = ({ onVariablesUpdated, }: { resultId?: string - onNewAnswer: (answer: Answer) => Promise | undefined + onNewAnswer: ( + answer: Answer & { uploadedFiles: boolean } + ) => Promise | undefined onVariablesUpdated?: (variables: VariableWithValue[]) => void children: ReactNode }) => { @@ -27,7 +31,7 @@ export const AnswersContext = ({ createdAt: new Date().toISOString(), }) - const addAnswer = (answer: Answer) => { + const addAnswer = (answer: Answer & { uploadedFiles: boolean }) => { setResultValues((resultValues) => ({ ...resultValues, answers: [...resultValues.answers, answer], diff --git a/packages/db/prisma/migrations/20220621144946_add_usage_fields/migration.sql b/packages/db/prisma/migrations/20220621144946_add_usage_fields/migration.sql new file mode 100644 index 000000000..9646a486a --- /dev/null +++ b/packages/db/prisma/migrations/20220621144946_add_usage_fields/migration.sql @@ -0,0 +1,22 @@ +/* + Warnings: + + - Made the column `groups` on table `PublicTypebot` required. This step will fail if there are existing NULL values in that column. + - Made the column `edges` on table `PublicTypebot` required. This step will fail if there are existing NULL values in that column. + - Made the column `groups` on table `Typebot` required. This step will fail if there are existing NULL values in that column. + - Made the column `edges` on table `Typebot` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Answer" ADD COLUMN "storageUsed" INTEGER; + +-- AlterTable +ALTER TABLE "PublicTypebot" ALTER COLUMN "groups" SET NOT NULL, +ALTER COLUMN "edges" SET NOT NULL; + +-- AlterTable +ALTER TABLE "Result" ADD COLUMN "hasStarted" BOOLEAN; + +-- AlterTable +ALTER TABLE "Typebot" ALTER COLUMN "groups" SET NOT NULL, +ALTER COLUMN "edges" SET NOT NULL; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 78b462f2e..629344e9d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -112,15 +112,15 @@ enum GraphNavigation { } model CustomDomain { - name String @id - createdAt DateTime @default(now()) + name String @id + createdAt DateTime @default(now()) workspaceId String workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) } model Credentials { - id String @id @default(cuid()) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + createdAt DateTime @default(now()) workspaceId String workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) data String // Encrypted data @@ -155,7 +155,7 @@ model DashboardFolder { childrenFolder DashboardFolder[] @relation("ParentChild") typebots Typebot[] workspaceId String - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) } model Typebot { @@ -180,7 +180,7 @@ model Typebot { invitations Invitation[] webhooks Webhook[] workspaceId String - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) } model Invitation { @@ -210,16 +210,16 @@ enum CollaborationType { } model PublicTypebot { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - typebotId String @unique - typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade) - groups Json - variables Json[] - edges Json - theme Json - settings Json + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + typebotId String @unique + typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade) + groups Json + variables Json[] + edges Json + theme Json + settings Json } model Result { @@ -231,6 +231,7 @@ model Result { answers Answer[] variables Json[] isCompleted Boolean + hasStarted Boolean? logs Log[] } @@ -248,10 +249,11 @@ model Answer { createdAt DateTime @default(now()) resultId String result Result @relation(fields: [resultId], references: [id], onDelete: Cascade) - blockId String + blockId String groupId String variableId String? content String + storageUsed Int? @@unique([resultId, blockId, groupId]) } diff --git a/packages/models/src/answer.ts b/packages/models/src/answer.ts index f8d86d4c7..4d33a0070 100644 --- a/packages/models/src/answer.ts +++ b/packages/models/src/answer.ts @@ -1,6 +1,9 @@ import { Answer as AnswerFromPrisma } from 'db' -export type Answer = Omit +export type Answer = Omit< + AnswerFromPrisma, + 'resultId' | 'createdAt' | 'storageUsed' +> & { storageUsed?: number } export type Stats = { totalViews: number diff --git a/packages/models/src/typebot/blocks/input/file.ts b/packages/models/src/typebot/blocks/input/file.ts index 2ad1ed7b6..cea8b91a5 100644 --- a/packages/models/src/typebot/blocks/input/file.ts +++ b/packages/models/src/typebot/blocks/input/file.ts @@ -8,6 +8,7 @@ export const fileInputOptionsSchema = optionBaseSchema.and( placeholder: z.string(), button: z.string(), }), + sizeLimit: z.number().optional(), }) ) diff --git a/packages/utils/src/api/storage.ts b/packages/utils/src/api/storage.ts index 405f23080..4e5fd3b20 100644 --- a/packages/utils/src/api/storage.ts +++ b/packages/utils/src/api/storage.ts @@ -3,14 +3,16 @@ import { config, Endpoint, S3 } from 'aws-sdk' type GeneratePresignedUrlProps = { filePath: string fileType: string + sizeLimit?: number } -const tenMB = 10485760 +const tenMB = 10 * 1024 * 1024 const oneHundredAndTwentySeconds = 120 export const generatePresignedUrl = ({ filePath, fileType, + sizeLimit = tenMB, }: GeneratePresignedUrlProps): S3.PresignedPost => { if ( !process.env.S3_ENDPOINT || @@ -45,7 +47,7 @@ export const generatePresignedUrl = ({ 'Content-Type': fileType, }, Expires: oneHundredAndTwentySeconds, - Conditions: [['content-length-range', 0, tenMB]], + Conditions: [['content-length-range', 0, sizeLimit]], }) return presignedUrl } diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts index 9da82afa4..e43c1a711 100644 --- a/packages/utils/src/utils.ts +++ b/packages/utils/src/utils.ts @@ -193,6 +193,7 @@ export const generateId = (idDesiredLength: number): string => { } type UploadFileProps = { + basePath?: string files: { file: File path: string @@ -202,6 +203,7 @@ type UploadFileProps = { type UrlList = string[] export const uploadFiles = async ({ + basePath = '/api', files, onUploadProgress, }: UploadFileProps): Promise => { @@ -209,9 +211,9 @@ export const uploadFiles = async ({ const { data } = await sendRequest<{ presignedUrl: { url: string; fields: any } }>( - `/api/storage/upload-url?filePath=${encodeURIComponent(path)}&fileType=${ - file.type - }` + `${basePath}/storage/upload-url?filePath=${encodeURIComponent( + path + )}&fileType=${file.type}` ) if (!data?.presignedUrl) return null