diff --git a/apps/docs/api-reference/chat/generate-upload-url.mdx b/apps/docs/api-reference/chat/generate-upload-url.mdx new file mode 100644 index 000000000..93b764bb1 --- /dev/null +++ b/apps/docs/api-reference/chat/generate-upload-url.mdx @@ -0,0 +1,20 @@ +--- +title: 'Generate upload URL' +openapi: POST /v2/generate-upload-url +--- + +The `presignedUrl` and `formData` fields can be then used to upload the file to the S3 bucket, directly from the browser if necessary. Here is an example: + +```js +// data contains the presignedUrl and formData fields from the response + +const formData = new FormData() +Object.entries(data.formData).forEach(([key, value]) => { + formData.append(key, value) +}) +formData.append('file', file) +const upload = await fetch(data.presignedUrl, { + method: 'POST', + body: formData, +}) +``` diff --git a/apps/docs/mint.json b/apps/docs/mint.json index 5bb32a784..91736ee5a 100644 --- a/apps/docs/mint.json +++ b/apps/docs/mint.json @@ -265,6 +265,7 @@ "api-reference/chat/start-preview-chat", "api-reference/chat/continue-chat", "api-reference/chat/save-logs", + "api-reference/chat/generate-upload-url", "api-reference/chat/update-typebot-in-session" ] }, diff --git a/apps/docs/openapi/builder.json b/apps/docs/openapi/builder.json index 462f6f8c1..5639ea131 100644 --- a/apps/docs/openapi/builder.json +++ b/apps/docs/openapi/builder.json @@ -273,6 +273,20 @@ }, {} ] + }, + "waitForEvent": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "saveDataInVariableId": { + "type": "string" + } + } } } } @@ -4595,6 +4609,20 @@ }, {} ] + }, + "waitForEvent": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "saveDataInVariableId": { + "type": "string" + } + } } } } @@ -8054,6 +8082,20 @@ }, {} ] + }, + "waitForEvent": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "saveDataInVariableId": { + "type": "string" + } + } } } } @@ -16555,6 +16597,20 @@ }, {} ] + }, + "waitForEvent": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "saveDataInVariableId": { + "type": "string" + } + } } } } @@ -22853,6 +22909,20 @@ }, {} ] + }, + "waitForEvent": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "saveDataInVariableId": { + "type": "string" + } + } } } } @@ -25681,6 +25751,20 @@ }, {} ] + }, + "waitForEvent": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "saveDataInVariableId": { + "type": "string" + } + } } } } diff --git a/apps/docs/openapi/viewer.json b/apps/docs/openapi/viewer.json index 314256ac8..15a4a4988 100644 --- a/apps/docs/openapi/viewer.json +++ b/apps/docs/openapi/viewer.json @@ -2310,7 +2310,7 @@ }, "/v1/generate-upload-url": { "post": { - "operationId": "generateUploadUrl", + "operationId": "generateUploadUrlV1", "summary": "Generate upload URL", "description": "Used to upload anything from the client to S3 bucket", "requestBody": { @@ -2424,6 +2424,87 @@ } } }, + "/v2/generate-upload-url": { + "post": { + "operationId": "generateUploadUrl", + "summary": "Generate upload URL", + "description": "Used to upload anything from the client to S3 bucket", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "fileName": { + "type": "string" + }, + "fileType": { + "type": "string" + } + }, + "required": [ + "sessionId", + "fileName" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "presignedUrl": { + "type": "string" + }, + "formData": { + "type": "object", + "additionalProperties": {} + }, + "fileUrl": { + "type": "string" + } + }, + "required": [ + "presignedUrl", + "formData", + "fileUrl" + ] + } + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, "/v1/sessions/{sessionId}/updateTypebot": { "post": { "operationId": "updateTypebotInSession", @@ -3389,6 +3470,20 @@ }, {} ] + }, + "waitForEvent": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "saveDataInVariableId": { + "type": "string" + } + } } } } @@ -7536,6 +7631,20 @@ }, {} ] + }, + "waitForEvent": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "saveDataInVariableId": { + "type": "string" + } + } } } } @@ -12720,6 +12829,20 @@ "url": { "type": "string" }, + "waitForEvent": { + "type": "object", + "properties": { + "isEnabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "saveDataInVariableId": { + "type": "string" + } + } + }, "height": { "type": "number" } diff --git a/apps/viewer/src/features/fileUpload/api/deprecated/generateUploadUrl.ts b/apps/viewer/src/features/fileUpload/api/deprecated/generateUploadUrl.ts new file mode 100644 index 000000000..de2576fdc --- /dev/null +++ b/apps/viewer/src/features/fileUpload/api/deprecated/generateUploadUrl.ts @@ -0,0 +1,188 @@ +import { publicProcedure } from '@/helpers/server/trpc' +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import { generatePresignedPostPolicy } from '@typebot.io/lib/s3/generatePresignedPostPolicy' +import { env } from '@typebot.io/env' +import prisma from '@typebot.io/lib/prisma' +import { getSession } from '@typebot.io/bot-engine/queries/getSession' +import { parseGroups } from '@typebot.io/schemas' +import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants' +import { getBlockById } from '@typebot.io/schemas/helpers' + +export const generateUploadUrl = publicProcedure + .meta({ + openapi: { + method: 'POST', + path: '/v1/generate-upload-url', + summary: 'Generate upload URL', + description: 'Used to upload anything from the client to S3 bucket', + }, + }) + .input( + z.object({ + filePathProps: z + .object({ + typebotId: z.string(), + blockId: z.string(), + resultId: z.string(), + fileName: z.string(), + }) + .or( + z.object({ + sessionId: z.string(), + fileName: z.string(), + }) + ), + fileType: z.string().optional(), + }) + ) + .output( + z.object({ + presignedUrl: z.string(), + formData: z.record(z.string(), z.any()), + fileUrl: z.string(), + }) + ) + .mutation(async ({ input: { filePathProps, fileType } }) => { + if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY) + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: + 'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY', + }) + + if ('typebotId' in filePathProps) { + const publicTypebot = await prisma.publicTypebot.findFirst({ + where: { + typebotId: filePathProps.typebotId, + }, + select: { + version: true, + groups: true, + typebot: { + select: { + workspaceId: true, + }, + }, + }, + }) + + const workspaceId = publicTypebot?.typebot.workspaceId + + if (!workspaceId) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: "Can't find workspaceId", + }) + + const filePath = `public/workspaces/${workspaceId}/typebots/${filePathProps.typebotId}/results/${filePathProps.resultId}/${filePathProps.fileName}` + + const fileUploadBlock = parseGroups(publicTypebot.groups, { + typebotVersion: publicTypebot.version, + }) + .flatMap((group) => group.blocks) + .find((block) => block.id === filePathProps.blockId) + + if (fileUploadBlock?.type !== InputBlockType.FILE) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: "Can't find file upload block", + }) + + const presignedPostPolicy = await generatePresignedPostPolicy({ + fileType, + filePath, + maxFileSize: + fileUploadBlock.options?.sizeLimit ?? + env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE, + }) + + return { + presignedUrl: presignedPostPolicy.postURL, + formData: presignedPostPolicy.formData, + fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN + ? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}` + : `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`, + } + } + + const session = await getSession(filePathProps.sessionId) + + if (!session) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: "Can't find session", + }) + + const typebotId = session.state.typebotsQueue[0].typebot.id + + const publicTypebot = await prisma.publicTypebot.findFirst({ + where: { + typebotId, + }, + select: { + version: true, + groups: true, + typebot: { + select: { + workspaceId: true, + }, + }, + }, + }) + + const workspaceId = publicTypebot?.typebot.workspaceId + + if (!workspaceId) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: "Can't find workspaceId", + }) + + if (session.state.currentBlockId === undefined) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: "Can't find currentBlockId in session state", + }) + + const { block: fileUploadBlock } = getBlockById( + session.state.currentBlockId, + parseGroups(publicTypebot.groups, { + typebotVersion: publicTypebot.version, + }) + ) + + if (fileUploadBlock?.type !== InputBlockType.FILE) + throw new TRPCError({ + code: 'BAD_REQUEST', + message: "Can't find file upload block", + }) + + const resultId = session.state.typebotsQueue[0].resultId + + const filePath = `${ + fileUploadBlock.options?.visibility === 'Private' ? 'private' : 'public' + }/workspaces/${workspaceId}/typebots/${typebotId}/results/${resultId}/${ + filePathProps.fileName + }` + + const presignedPostPolicy = await generatePresignedPostPolicy({ + fileType, + filePath, + maxFileSize: + fileUploadBlock.options && 'sizeLimit' in fileUploadBlock.options + ? (fileUploadBlock.options.sizeLimit as number) + : env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE, + }) + + return { + presignedUrl: presignedPostPolicy.postURL, + formData: presignedPostPolicy.formData, + fileUrl: + fileUploadBlock.options?.visibility === 'Private' + ? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${filePathProps.fileName}` + : env.S3_PUBLIC_CUSTOM_DOMAIN + ? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}` + : `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`, + } + }) diff --git a/apps/viewer/src/features/fileUpload/api/generateUploadUrl.ts b/apps/viewer/src/features/fileUpload/api/generateUploadUrl.ts index 92457bcf8..506650bb8 100644 --- a/apps/viewer/src/features/fileUpload/api/generateUploadUrl.ts +++ b/apps/viewer/src/features/fileUpload/api/generateUploadUrl.ts @@ -13,26 +13,15 @@ export const generateUploadUrl = publicProcedure .meta({ openapi: { method: 'POST', - path: '/v1/generate-upload-url', + path: '/v2/generate-upload-url', summary: 'Generate upload URL', description: 'Used to upload anything from the client to S3 bucket', }, }) .input( z.object({ - filePathProps: z - .object({ - typebotId: z.string(), - blockId: z.string(), - resultId: z.string(), - fileName: z.string(), - }) - .or( - z.object({ - sessionId: z.string(), - fileName: z.string(), - }) - ), + sessionId: z.string(), + fileName: z.string(), fileType: z.string().optional(), }) ) @@ -43,7 +32,7 @@ export const generateUploadUrl = publicProcedure fileUrl: z.string(), }) ) - .mutation(async ({ input: { filePathProps, fileType } }) => { + .mutation(async ({ input: { fileName, sessionId, fileType } }) => { if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', @@ -51,63 +40,7 @@ export const generateUploadUrl = publicProcedure 'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY', }) - // TODO: Remove (deprecated) - if ('typebotId' in filePathProps) { - const publicTypebot = await prisma.publicTypebot.findFirst({ - where: { - typebotId: filePathProps.typebotId, - }, - select: { - version: true, - groups: true, - typebot: { - select: { - workspaceId: true, - }, - }, - }, - }) - - const workspaceId = publicTypebot?.typebot.workspaceId - - if (!workspaceId) - throw new TRPCError({ - code: 'BAD_REQUEST', - message: "Can't find workspaceId", - }) - - const filePath = `public/workspaces/${workspaceId}/typebots/${filePathProps.typebotId}/results/${filePathProps.resultId}/${filePathProps.fileName}` - - const fileUploadBlock = parseGroups(publicTypebot.groups, { - typebotVersion: publicTypebot.version, - }) - .flatMap((group) => group.blocks) - .find((block) => block.id === filePathProps.blockId) - - if (fileUploadBlock?.type !== InputBlockType.FILE) - throw new TRPCError({ - code: 'BAD_REQUEST', - message: "Can't find file upload block", - }) - - const presignedPostPolicy = await generatePresignedPostPolicy({ - fileType, - filePath, - maxFileSize: - fileUploadBlock.options?.sizeLimit ?? - env.NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE, - }) - - return { - presignedUrl: presignedPostPolicy.postURL, - formData: presignedPostPolicy.formData, - fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN - ? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}` - : `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`, - } - } - - const session = await getSession(filePathProps.sessionId) + const session = await getSession(sessionId) if (!session) throw new TRPCError({ @@ -163,9 +96,7 @@ export const generateUploadUrl = publicProcedure const filePath = `${ fileUploadBlock.options?.visibility === 'Private' ? 'private' : 'public' - }/workspaces/${workspaceId}/typebots/${typebotId}/results/${resultId}/${ - filePathProps.fileName - }` + }/workspaces/${workspaceId}/typebots/${typebotId}/results/${resultId}/${fileName}` const presignedPostPolicy = await generatePresignedPostPolicy({ fileType, @@ -181,7 +112,7 @@ export const generateUploadUrl = publicProcedure formData: presignedPostPolicy.formData, fileUrl: fileUploadBlock.options?.visibility === 'Private' - ? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${filePathProps.fileName}` + ? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${fileName}` : env.S3_PUBLIC_CUSTOM_DOMAIN ? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}` : `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`, diff --git a/apps/viewer/src/helpers/server/appRouter.ts b/apps/viewer/src/helpers/server/appRouter.ts index f19ae76b2..1e3c4ba4a 100644 --- a/apps/viewer/src/helpers/server/appRouter.ts +++ b/apps/viewer/src/helpers/server/appRouter.ts @@ -3,6 +3,7 @@ import { whatsAppRouter } from '@/features/whatsapp/api/router' import { router } from './trpc' import { updateTypebotInSession } from '@/features/chat/api/updateTypebotInSession' import { getUploadUrl } from '@/features/fileUpload/api/deprecated/getUploadUrl' +import { generateUploadUrl as generateUploadUrlV1 } from '@/features/fileUpload/api/deprecated/generateUploadUrl' import { generateUploadUrl } from '@/features/fileUpload/api/generateUploadUrl' import { sendMessageV2 } from '@/features/chat/api/legacy/sendMessageV2' import { continueChat } from '@/features/chat/api/continueChat' @@ -17,6 +18,7 @@ export const appRouter = router({ continueChat, startChatPreview: startChatPreview, getUploadUrl, + generateUploadUrlV1, generateUploadUrl, updateTypebotInSession, whatsAppRouter, diff --git a/packages/embeds/js/package.json b/packages/embeds/js/package.json index fff491368..df2575623 100644 --- a/packages/embeds/js/package.json +++ b/packages/embeds/js/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/js", - "version": "0.2.89", + "version": "0.2.90", "description": "Javascript library to display typebots on your website", "type": "module", "main": "dist/index.js", diff --git a/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/uploadFiles.ts b/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/uploadFiles.ts index d94bb09e6..d6e30fb97 100644 --- a/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/uploadFiles.ts +++ b/packages/embeds/js/src/features/blocks/inputs/fileUpload/helpers/uploadFiles.ts @@ -30,9 +30,10 @@ export const uploadFiles = async ({ fileUrl: string }>({ method: 'POST', - url: `${apiHost}/api/v1/generate-upload-url`, + url: `${apiHost}/api/v2/generate-upload-url`, body: { - filePathProps: input, + fileName: input.fileName, + sessionId: input.sessionId, fileType: file.type, }, }) diff --git a/packages/embeds/nextjs/package.json b/packages/embeds/nextjs/package.json index 226d259c8..d5df241cd 100644 --- a/packages/embeds/nextjs/package.json +++ b/packages/embeds/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/nextjs", - "version": "0.2.89", + "version": "0.2.90", "description": "Convenient library to display typebots on your Next.js website", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/embeds/react/package.json b/packages/embeds/react/package.json index 77350456d..936792665 100644 --- a/packages/embeds/react/package.json +++ b/packages/embeds/react/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/react", - "version": "0.2.89", + "version": "0.2.90", "description": "Convenient library to display typebots on your React app", "main": "dist/index.js", "types": "dist/index.d.ts",