From fd822a35a773cf71b25c0f7dce707b5335f8effa Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Mon, 21 Feb 2022 16:53:22 +0100 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E2=9C=A8=20Add=20get=20sample=20r?= =?UTF-8?q?esult=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/builder/services/results.ts | 29 +- .../[blockId]/steps/[stepId]/sampleResult.ts | 98 +++++ .../playwright/fixtures/typebots/api.json | 381 ++++++++++++++++++ apps/viewer/playwright/tests/api.spec.ts | 66 +-- 4 files changed, 519 insertions(+), 55 deletions(-) create mode 100644 apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/sampleResult.ts create mode 100644 apps/viewer/playwright/fixtures/typebots/api.json diff --git a/apps/builder/services/results.ts b/apps/builder/services/results.ts index 455b90f49..fe181ef93 100644 --- a/apps/builder/services/results.ts +++ b/apps/builder/services/results.ts @@ -1,9 +1,9 @@ -import { ResultWithAnswers, Typebot, VariableWithValue } from 'models' +import { ResultWithAnswers, VariableWithValue } from 'models' import useSWRInfinite from 'swr/infinite' import { fetcher } from './utils' import { stringify } from 'qs' import { Answer } from 'db' -import { byId, isDefined, sendRequest } from 'utils' +import { isDefined, sendRequest } from 'utils' const paginationLimit = 50 @@ -112,28 +112,3 @@ export const convertResultsToTableData = (results?: ResultWithAnswers[]) => return { ...o, [variable.id]: variable.value } }, {}), })) - -export const parseAnswers = ( - result: ResultWithAnswers, - { blocks, variables }: Pick -) => ({ - submittedAt: result.createdAt, - ...[...result.answers, ...result.prefilledVariables].reduce<{ - [key: string]: string - }>((o, answerOrVariable) => { - if ('blockId' in answerOrVariable) { - const answer = answerOrVariable as Answer - const key = answer.variableId - ? variables.find(byId(answer.variableId))?.name - : blocks.find(byId(answer.blockId))?.title - if (!key) return o - return { - ...o, - [key]: answer.content, - } - } - const variable = answerOrVariable as VariableWithValue - if (isDefined(o[variable.id])) return o - return { ...o, [variable.id]: variable.value } - }, {}), -}) diff --git a/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/sampleResult.ts b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/sampleResult.ts new file mode 100644 index 000000000..ba2a043d0 --- /dev/null +++ b/apps/viewer/pages/api/typebots/[typebotId]/blocks/[blockId]/steps/[stepId]/sampleResult.ts @@ -0,0 +1,98 @@ +import prisma from 'libs/prisma' +import { Block, InputStep, InputStepType, Typebot } from 'models' +import { NextApiRequest, NextApiResponse } from 'next' +import { authenticateUser } from 'services/api/utils' +import { byId, isDefined, isInputStep, methodNotAllowed } from 'utils' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === 'GET') { + const user = await authenticateUser(req) + if (!user) return res.status(401).json({ message: 'Not authenticated' }) + const typebotId = req.query.typebotId.toString() + const blockId = req.query.blockId.toString() + const typebot = (await prisma.typebot.findUnique({ + where: { id_ownerId: { id: typebotId, ownerId: user.id } }, + })) as Typebot | undefined + if (!typebot) return res.status(400).send({ message: 'Typebot not found' }) + const previousBlockIds = getPreviousBlocks(typebot)(blockId) + const previousBlocks = typebot.blocks.filter((b) => + previousBlockIds.includes(b.id) + ) + return res.send(parseSampleResult(typebot)(previousBlocks)) + } + methodNotAllowed(res) +} + +const parseSampleResult = + (typebot: Typebot) => + (blocks: Block[]): Record => { + const parsedBlocks = parseBlocksResultSample(typebot, blocks) + return { + message: 'This is a sample result, it has been generated ⬇️', + 'Submitted at': new Date().toISOString(), + ...parsedBlocks, + ...parseVariablesHeaders(typebot, parsedBlocks), + } + } + +const parseBlocksResultSample = (typebot: Typebot, blocks: Block[]) => + blocks + .filter((block) => typebot && block.steps.some((step) => isInputStep(step))) + .reduce>((blocks, block) => { + const inputStep = block.steps.find((step) => isInputStep(step)) + if (!inputStep || !isInputStep(inputStep)) return blocks + const matchedVariableName = + inputStep.options.variableId && + typebot.variables.find(byId(inputStep.options.variableId))?.name + const value = getSampleValue(inputStep) + return { + ...blocks, + [matchedVariableName ?? block.title]: value, + } + }, {}) + +const getSampleValue = (step: InputStep) => { + switch (step.type) { + case InputStepType.CHOICE: + return 'Item 1, Item 2, Item3' + case InputStepType.DATE: + return new Date().toUTCString() + case InputStepType.EMAIL: + return 'test@email.com' + case InputStepType.NUMBER: + return '20' + case InputStepType.PHONE: + return '+33665566773' + case InputStepType.TEXT: + return 'answer value' + case InputStepType.URL: + return 'https://test.com' + } +} + +const parseVariablesHeaders = ( + typebot: Typebot, + parsedBlocks: Record +) => + typebot.variables.reduce>((headers, v) => { + if (parsedBlocks[v.name]) return headers + return { + ...headers, + [v.name]: 'value', + } + }, {}) + +const getPreviousBlocks = + (typebot: Typebot) => + (blockId: string): string[] => { + const previousBlocks = typebot.edges + .map((edge) => + edge.to.blockId === blockId ? edge.from.blockId : undefined + ) + .filter(isDefined) + return previousBlocks.concat( + previousBlocks.flatMap(getPreviousBlocks(typebot)) + ) + } + +export default handler diff --git a/apps/viewer/playwright/fixtures/typebots/api.json b/apps/viewer/playwright/fixtures/typebots/api.json new file mode 100644 index 000000000..8c4f9f4e6 --- /dev/null +++ b/apps/viewer/playwright/fixtures/typebots/api.json @@ -0,0 +1,381 @@ +{ + "id": "qujHPjZ44xbrHb1hS1d8qC", + "createdAt": "2022-02-05T06:21:16.522Z", + "updatedAt": "2022-02-05T06:21:16.522Z", + "name": "My typebot", + "ownerId": "ckzwaq0p000149f1a2ejh3qm0", + "publishedTypebotId": null, + "folderId": null, + "blocks": [ + { + "id": "k6kY6gwRE6noPoYQNGzgUq", + "steps": [ + { + "id": "22HP69iipkLjJDTUcc1AWW", + "type": "start", + "label": "Start", + "blockId": "k6kY6gwRE6noPoYQNGzgUq", + "outgoingEdgeId": "oNvqaqNExdSH2kKEhKZHuE" + } + ], + "title": "Start", + "graphCoordinates": { "x": 0, "y": 0 } + }, + { + "id": "kinRXxYop2X4d7F9qt8WNB", + "steps": [ + { + "id": "sc1y8VwDabNJgiVTBi4qtif", + "type": "text", + "blockId": "kinRXxYop2X4d7F9qt8WNB", + "content": { + "html": "
Welcome to AA (Awesome Agency)
", + "richText": [ + { + "type": "p", + "children": [ + { "text": "Welcome to " }, + { "bold": true, "text": "AA" }, + { "text": " (Awesome Agency)" } + ] + } + ], + "plainText": "Welcome to AA (Awesome Agency)" + } + }, + { + "id": "s7YqZTBeyCa4Hp3wN2j922c", + "type": "image", + "blockId": "kinRXxYop2X4d7F9qt8WNB", + "content": { + "url": "https://media2.giphy.com/media/XD9o33QG9BoMis7iM4/giphy.gif?cid=fe3852a3ihg8rvipzzky5lybmdyq38fhke2tkrnshwk52c7d&rid=giphy.gif&ct=g" + } + }, + { + "id": "sbjZWLJGVkHAkDqS4JQeGow", + "type": "choice input", + "items": [ + { + "id": "hQw2zbp7FDX7XYK9cFpbgC", + "type": 0, + "stepId": "sbjZWLJGVkHAkDqS4JQeGow", + "content": "Hi!" + } + ], + "blockId": "kinRXxYop2X4d7F9qt8WNB", + "options": { "buttonLabel": "Send", "isMultipleChoice": false }, + "outgoingEdgeId": "i51YhHpk1dtSyduFNf5Wim" + } + ], + "title": "Welcome", + "graphCoordinates": { "x": 1, "y": 148 } + }, + { + "id": "o4SH1UtKANnW5N5D67oZUz", + "steps": [ + { + "id": "sxeYubYN6XzhAfG7m9Fivhc", + "type": "text", + "blockId": "o4SH1UtKANnW5N5D67oZUz", + "content": { + "html": "
Great! Nice to meet you {{Name}}
", + "richText": [ + { + "type": "p", + "children": [{ "text": "Great! Nice to meet you {{Name}}" }] + } + ], + "plainText": "Great! Nice to meet you {{Name}}" + } + }, + { + "id": "scQ5kduafAtfP9T8SHUJnGi", + "type": "text", + "blockId": "o4SH1UtKANnW5N5D67oZUz", + "content": { + "html": "
What's the best email we can reach you at?
", + "richText": [ + { + "type": "p", + "children": [ + { "text": "What's the best email we can reach you at?" } + ] + } + ], + "plainText": "What's the best email we can reach you at?" + } + }, + { + "id": "snbsad18Bgry8yZ8DZCfdFD", + "type": "email input", + "blockId": "o4SH1UtKANnW5N5D67oZUz", + "options": { + "labels": { "button": "Send", "placeholder": "Type your email..." }, + "variableId": "3VFChNVSCXQ2rXv4DrJ8Ah" + }, + "outgoingEdgeId": "w3MiN1Ct38jT5NykVsgmb5" + } + ], + "title": "Email", + "graphCoordinates": { "x": 669, "y": 141 } + }, + { + "id": "q5dAhqSTCaNdiGSJm9B9Rw", + "steps": [ + { + "id": "sgtE2Sy7cKykac9B223Kq9R", + "type": "text", + "blockId": "q5dAhqSTCaNdiGSJm9B9Rw", + "content": { + "html": "
What's your name?
", + "richText": [ + { "type": "p", "children": [{ "text": "What's your name?" }] } + ], + "plainText": "What's your name?" + } + }, + { + "id": "sqEsMo747LTDnY9FjQcEwUv", + "type": "text input", + "blockId": "q5dAhqSTCaNdiGSJm9B9Rw", + "options": { + "isLong": false, + "labels": { + "button": "Send", + "placeholder": "Type your answer..." + }, + "variableId": "giiLFGw5xXBCHzvp1qAbdX" + }, + "outgoingEdgeId": "4tYbERpi5Po4goVgt6rWXg" + } + ], + "title": "Name", + "graphCoordinates": { "x": 340, "y": 143 } + }, + { + "id": "fKqRz7iswk7ULaj5PJocZL", + "steps": [ + { + "id": "su7HceVXWyTCzi2vv3m4QbK", + "type": "text", + "blockId": "fKqRz7iswk7ULaj5PJocZL", + "content": { + "html": "
What services are you interested in?
", + "richText": [ + { + "type": "p", + "children": [{ "text": "What services are you interested in?" }] + } + ], + "plainText": "What services are you interested in?" + } + }, + { + "id": "s5VQGsVF4hQgziQsXVdwPDW", + "type": "choice input", + "items": [ + { + "id": "fnLCBF4NdraSwcubnBhk8H", + "type": 0, + "stepId": "s5VQGsVF4hQgziQsXVdwPDW", + "content": "Website dev" + }, + { + "id": "a782h8ynMouY84QjH7XSnR", + "type": 0, + "stepId": "s5VQGsVF4hQgziQsXVdwPDW", + "content": "Content Marketing" + }, + { + "id": "jGvh94zBByvVFpSS3w97zY", + "type": 0, + "stepId": "s5VQGsVF4hQgziQsXVdwPDW", + "content": "Social Media" + }, + { + "id": "6PRLbKUezuFmwWtLVbvAQ7", + "type": 0, + "stepId": "s5VQGsVF4hQgziQsXVdwPDW", + "content": "UI / UX Design" + } + ], + "blockId": "fKqRz7iswk7ULaj5PJocZL", + "options": { "buttonLabel": "Send", "isMultipleChoice": true }, + "outgoingEdgeId": "ohTRakmcYJ7GdFWRZrWRjk" + } + ], + "title": "Services", + "graphCoordinates": { "x": 1002, "y": 144 } + }, + { + "id": "7qHBEyCMvKEJryBHzPmHjV", + "steps": [ + { + "id": "sqR8Sz9gW21aUYKtUikq7qZ", + "type": "text", + "blockId": "7qHBEyCMvKEJryBHzPmHjV", + "content": { + "html": "
Can you tell me a bit more about your needs?
", + "richText": [ + { + "type": "p", + "children": [ + { "text": "Can you tell me a bit more about your needs?" } + ] + } + ], + "plainText": "Can you tell me a bit more about your needs?" + } + }, + { + "id": "sqFy2G3C1mh9p6s3QBdSS5x", + "type": "text input", + "blockId": "7qHBEyCMvKEJryBHzPmHjV", + "options": { + "isLong": true, + "labels": { "button": "Send", "placeholder": "Type your answer..." } + }, + "outgoingEdgeId": "sH5nUssG2XQbm6ZidGv9BY" + } + ], + "title": "Additional information", + "graphCoordinates": { "x": 1337, "y": 145 } + }, + { + "id": "vF7AD7zSAj7SNvN3gr9N94", + "steps": [ + { + "id": "seLegenCgUwMopRFeAefqZ7", + "type": "text", + "blockId": "vF7AD7zSAj7SNvN3gr9N94", + "content": { + "html": "
Perfect!
", + "richText": [{ "type": "p", "children": [{ "text": "Perfect!" }] }], + "plainText": "Perfect!" + } + }, + { + "id": "s779Q1y51aVaDUJVrFb16vv", + "type": "text", + "blockId": "vF7AD7zSAj7SNvN3gr9N94", + "content": { + "html": "
We'll get back to you at {{Email}}
", + "richText": [ + { + "type": "p", + "children": [{ "text": "We'll get back to you at {{Email}}" }] + } + ], + "plainText": "We'll get back to you at {{Email}}" + }, + "outgoingEdgeId": "fTVo43AG97eKcaTrZf9KyV" + } + ], + "title": "Bye", + "graphCoordinates": { "x": 1668, "y": 143 } + }, + { + "id": "webhookBlock", + "graphCoordinates": { "x": 1996, "y": 134 }, + "title": "Webhook", + "steps": [ + { + "id": "webhookStep", + "blockId": "webhookBlock", + "type": "Webhook", + "options": { "responseVariableMapping": [], "variablesForTest": [] }, + "webhook": { + "id": "3zZp4961n6CeorWR43jdV9", + "method": "GET", + "headers": [], + "queryParams": [] + } + } + ] + } + ], + "variables": [ + { "id": "giiLFGw5xXBCHzvp1qAbdX", "name": "Name" }, + { "id": "3VFChNVSCXQ2rXv4DrJ8Ah", "name": "Email" } + ], + "edges": [ + { + "id": "oNvqaqNExdSH2kKEhKZHuE", + "to": { "blockId": "kinRXxYop2X4d7F9qt8WNB" }, + "from": { + "stepId": "22HP69iipkLjJDTUcc1AWW", + "blockId": "k6kY6gwRE6noPoYQNGzgUq" + } + }, + { + "id": "i51YhHpk1dtSyduFNf5Wim", + "to": { "blockId": "q5dAhqSTCaNdiGSJm9B9Rw" }, + "from": { + "stepId": "sbjZWLJGVkHAkDqS4JQeGow", + "blockId": "kinRXxYop2X4d7F9qt8WNB" + } + }, + { + "id": "4tYbERpi5Po4goVgt6rWXg", + "to": { "blockId": "o4SH1UtKANnW5N5D67oZUz" }, + "from": { + "stepId": "sqEsMo747LTDnY9FjQcEwUv", + "blockId": "q5dAhqSTCaNdiGSJm9B9Rw" + } + }, + { + "id": "w3MiN1Ct38jT5NykVsgmb5", + "to": { "blockId": "fKqRz7iswk7ULaj5PJocZL" }, + "from": { + "stepId": "snbsad18Bgry8yZ8DZCfdFD", + "blockId": "o4SH1UtKANnW5N5D67oZUz" + } + }, + { + "id": "ohTRakmcYJ7GdFWRZrWRjk", + "to": { "blockId": "7qHBEyCMvKEJryBHzPmHjV" }, + "from": { + "stepId": "s5VQGsVF4hQgziQsXVdwPDW", + "blockId": "fKqRz7iswk7ULaj5PJocZL" + } + }, + { + "id": "sH5nUssG2XQbm6ZidGv9BY", + "to": { "blockId": "vF7AD7zSAj7SNvN3gr9N94" }, + "from": { + "stepId": "sqFy2G3C1mh9p6s3QBdSS5x", + "blockId": "7qHBEyCMvKEJryBHzPmHjV" + } + }, + { + "from": { + "blockId": "vF7AD7zSAj7SNvN3gr9N94", + "stepId": "s779Q1y51aVaDUJVrFb16vv" + }, + "to": { "blockId": "webhookBlock" }, + "id": "fTVo43AG97eKcaTrZf9KyV" + } + ], + "theme": { + "chat": { + "inputs": { + "color": "#303235", + "backgroundColor": "#FFFFFF", + "placeholderColor": "#9095A0" + }, + "buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" }, + "hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" }, + "guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" } + }, + "general": { "font": "Open Sans", "background": { "type": "None" } } + }, + "settings": { + "general": { "isBrandingEnabled": true }, + "metadata": { + "description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form." + }, + "typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 } + }, + "publicId": null, + "customDomain": null +} diff --git a/apps/viewer/playwright/tests/api.spec.ts b/apps/viewer/playwright/tests/api.spec.ts index b3f4609e6..317fa66af 100644 --- a/apps/viewer/playwright/tests/api.spec.ts +++ b/apps/viewer/playwright/tests/api.spec.ts @@ -1,28 +1,14 @@ import test, { expect } from '@playwright/test' -import { - createResults, - createTypebots, - parseDefaultBlockWithStep, -} from '../services/database' -import { - IntegrationStepType, - defaultWebhookOptions, - defaultWebhookAttributes, -} from 'models' +import { createResults, importTypebotInDatabase } from '../services/database' +import path from 'path' const typebotId = 'webhook-flow' test.beforeAll(async () => { try { - await createTypebots([ - { - id: typebotId, - ...parseDefaultBlockWithStep({ - type: IntegrationStepType.WEBHOOK, - options: defaultWebhookOptions, - webhook: { id: 'webhookId', ...defaultWebhookAttributes }, - }), - }, - ]) + await importTypebotInDatabase( + path.join(__dirname, '../fixtures/typebots/api.json'), + { id: typebotId } + ) await createResults({ typebotId }) } catch (err) {} }) @@ -54,9 +40,9 @@ test('can get webhook steps', async ({ request }) => { const { steps } = await response.json() expect(steps).toHaveLength(1) expect(steps[0]).toEqual({ - id: 'step1', - blockId: 'block1', - name: 'Block #1 > step1', + id: 'webhookStep', + blockId: 'webhookBlock', + name: 'Webhook > webhookStep', }) }) @@ -64,13 +50,13 @@ test('can subscribe webhook', async ({ request }) => { expect( ( await request.patch( - `/api/typebots/${typebotId}/blocks/block1/steps/step1/subscribeWebhook`, + `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/subscribeWebhook`, { data: { url: 'https://test.com' } } ) ).status() ).toBe(401) const response = await request.patch( - `/api/typebots/${typebotId}/blocks/block1/steps/step1/subscribeWebhook`, + `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/subscribeWebhook`, { headers: { Authorization: 'Bearer userToken', @@ -88,12 +74,12 @@ test('can unsubscribe webhook', async ({ request }) => { expect( ( await request.delete( - `/api/typebots/${typebotId}/blocks/block1/steps/step1/unsubscribeWebhook` + `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/unsubscribeWebhook` ) ).status() ).toBe(401) const response = await request.delete( - `/api/typebots/${typebotId}/blocks/block1/steps/step1/unsubscribeWebhook`, + `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/unsubscribeWebhook`, { headers: { Authorization: 'Bearer userToken' }, } @@ -104,6 +90,31 @@ test('can unsubscribe webhook', async ({ request }) => { }) }) +test('can get a sample result', async ({ request }) => { + expect( + ( + await request.get( + `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/sampleResult` + ) + ).status() + ).toBe(401) + const response = await request.get( + `/api/typebots/${typebotId}/blocks/webhookBlock/steps/webhookStep/sampleResult`, + { + headers: { Authorization: 'Bearer userToken' }, + } + ) + const data = await response.json() + expect(data).toMatchObject({ + message: 'This is a sample result, it has been generated ⬇️', + Welcome: 'Item 1, Item 2, Item3', + Email: 'test@email.com', + Name: 'answer value', + Services: 'Item 1, Item 2, Item3', + 'Additional information': 'answer value', + }) +}) + test('can list results', async ({ request }) => { expect( (await request.get(`/api/typebots/${typebotId}/results`)).status() @@ -116,5 +127,4 @@ test('can list results', async ({ request }) => { ) const { results } = await response.json() expect(results).toHaveLength(10) - expect(results[0]).toMatchObject({ 'Block #1': 'content199' }) })