Add webhook blocks API public endpoints

This commit is contained in:
Baptiste Arnaud
2022-11-30 13:57:28 +01:00
parent f9ffdbc4c5
commit c799717905
67 changed files with 3030 additions and 429 deletions

View File

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

View File

@@ -0,0 +1,74 @@
import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api'
import prisma from '@/lib/prisma'
import { canReadTypebot } from '@/utils/api/dbRules'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Typebot, Webhook } from 'models'
import { z } from 'zod'
import { parseResultExample } from '../utils'
export const getResultExampleProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/webhookBlocks/{blockId}/getResultExample',
protect: true,
summary: 'Get result example',
description:
'Returns "fake" result for webhook block to help you anticipate how the webhook will behave.',
tags: ['Webhook'],
},
})
.input(
z.object({
typebotId: z.string(),
blockId: z.string(),
})
)
.output(
z.object({
resultExample: z
.object({
message: z.literal(
'This is a sample result, it has been generated ⬇️'
),
'Submitted at': z.string(),
})
.and(z.record(z.string().optional()))
.describe('Can contain any fields.'),
})
)
.query(async ({ input: { typebotId, blockId }, ctx: { user } }) => {
const typebot = (await prisma.typebot.findFirst({
where: canReadTypebot(typebotId, user),
select: {
groups: true,
edges: true,
variables: true,
webhooks: true,
},
})) as
| (Pick<Typebot, 'groups' | 'edges' | 'variables'> & {
webhooks: Webhook[]
})
| null
if (!typebot)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const block = typebot.groups
.flatMap((g) => g.blocks)
.find((s) => s.id === blockId)
if (!block)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Block not found' })
const linkedTypebots = await getLinkedTypebots(typebot, user)
return {
resultExample: await parseResultExample(
typebot,
linkedTypebots
)(block.groupId),
}
})

View File

@@ -0,0 +1,4 @@
export * from './getResultExampleProcedure'
export * from './listWebhookBlocksProcedure'
export * from './subscribeWebhookProcedure'
export * from './unsubscribeWebhookProcedure'

View File

@@ -0,0 +1,65 @@
import prisma from '@/lib/prisma'
import { canReadTypebot } from '@/utils/api/dbRules'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Group, Typebot, Webhook, WebhookBlock } from 'models'
import { byId, isWebhookBlock } from 'utils'
import { z } from 'zod'
export const listWebhookBlocksProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/webhookBlocks',
protect: true,
summary: 'List webhook blocks',
description:
'Returns a list of all the webhook blocks that you can subscribe to.',
tags: ['Webhook'],
},
})
.input(
z.object({
typebotId: z.string(),
})
)
.output(
z.object({
webhookBlocks: z.array(
z.object({
id: z.string(),
label: z.string(),
url: z.string().optional(),
})
),
})
)
.query(async ({ input: { typebotId }, ctx: { user } }) => {
const typebot = (await prisma.typebot.findFirst({
where: canReadTypebot(typebotId, user),
select: {
groups: true,
webhooks: true,
},
})) as (Pick<Typebot, 'groups'> & { webhooks: Webhook[] }) | null
if (!typebot)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const webhookBlocks = (typebot?.groups as Group[]).reduce<
{ id: string; label: string; url: string | undefined }[]
>((webhookBlocks, group) => {
const blocks = group.blocks.filter((block) =>
isWebhookBlock(block)
) as WebhookBlock[]
return [
...webhookBlocks,
...blocks.map((b) => ({
id: b.id,
label: `${group.title} > ${b.id}`,
url: typebot?.webhooks.find(byId(b.webhookId))?.url ?? undefined,
})),
]
}, [])
return { webhookBlocks }
})

View File

@@ -0,0 +1,64 @@
import prisma from '@/lib/prisma'
import { canWriteTypebot } from '@/utils/api/dbRules'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Typebot, Webhook, WebhookBlock } from 'models'
import { byId, isWebhookBlock } from 'utils'
import { z } from 'zod'
export const subscribeWebhookProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/webhookBlocks/{blockId}/subscribe',
protect: true,
summary: 'Subscribe to webhook block',
tags: ['Webhook'],
},
})
.input(
z.object({
typebotId: z.string(),
blockId: z.string(),
url: z.string(),
})
)
.output(
z.object({
id: z.string(),
url: z.string().nullable(),
})
)
.query(async ({ input: { typebotId, blockId, url }, ctx: { user } }) => {
const typebot = (await prisma.typebot.findFirst({
where: canWriteTypebot(typebotId, user),
select: {
groups: true,
webhooks: true,
},
})) as (Pick<Typebot, 'groups'> & { webhooks: Webhook[] }) | null
if (!typebot)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const webhookBlock = typebot.groups
.flatMap((g) => g.blocks)
.find(byId(blockId)) as WebhookBlock | null
if (!webhookBlock || !isWebhookBlock(webhookBlock))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Webhook block not found',
})
await prisma.webhook.upsert({
where: { id: webhookBlock.webhookId },
update: { url, body: '{{state}}', method: 'POST' },
create: { url, body: '{{state}}', method: 'POST', typebotId },
})
return {
id: blockId,
url,
}
})

View File

@@ -0,0 +1,62 @@
import prisma from '@/lib/prisma'
import { canWriteTypebot } from '@/utils/api/dbRules'
import { authenticatedProcedure } from '@/utils/server/trpc'
import { TRPCError } from '@trpc/server'
import { Typebot, Webhook, WebhookBlock } from 'models'
import { byId, isWebhookBlock } from 'utils'
import { z } from 'zod'
export const unsubscribeWebhookProcedure = authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/typebots/{typebotId}/webhookBlocks/{blockId}/unsubscribe',
protect: true,
summary: 'Unsubscribe from webhook block',
tags: ['Webhook'],
},
})
.input(
z.object({
typebotId: z.string(),
blockId: z.string(),
})
)
.output(
z.object({
id: z.string(),
url: z.string().nullable(),
})
)
.query(async ({ input: { typebotId, blockId }, ctx: { user } }) => {
const typebot = (await prisma.typebot.findFirst({
where: canWriteTypebot(typebotId, user),
select: {
groups: true,
webhooks: true,
},
})) as (Pick<Typebot, 'groups'> & { webhooks: Webhook[] }) | null
if (!typebot)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
const webhookBlock = typebot.groups
.flatMap((g) => g.blocks)
.find(byId(blockId)) as WebhookBlock | null
if (!webhookBlock || !isWebhookBlock(webhookBlock))
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Webhook block not found',
})
await prisma.webhook.update({
where: { id: webhookBlock.webhookId },
data: { url: null },
})
return {
id: blockId,
url: null,
}
})

View File

@@ -0,0 +1,14 @@
import { router } from '@/utils/server/trpc'
import {
listWebhookBlocksProcedure,
subscribeWebhookProcedure,
unsubscribeWebhookProcedure,
getResultExampleProcedure,
} from './procedures'
export const webhookRouter = router({
listWebhookBlocks: listWebhookBlocksProcedure,
getResultExample: getResultExampleProcedure,
subscribeWebhook: subscribeWebhookProcedure,
unsubscribeWebhook: unsubscribeWebhookProcedure,
})

View File

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

View File

@@ -0,0 +1,197 @@
import {
InputBlock,
InputBlockType,
LogicBlockType,
PublicTypebot,
ResultHeaderCell,
Block,
Typebot,
TypebotLinkBlock,
} from 'models'
import { isInputBlock, byId, parseResultHeader, isNotDefined } from 'utils'
export const parseResultExample =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
linkedTypebots: (Typebot | PublicTypebot)[]
) =>
async (
currentGroupId: string
): Promise<
{
message: 'This is a sample result, it has been generated ⬇️'
'Submitted at': string
} & { [k: string]: string | undefined }
> => {
const header = parseResultHeader(typebot, linkedTypebots)
const linkedInputBlocks = await extractLinkedInputBlocks(
typebot,
linkedTypebots
)(currentGroupId)
return {
message: 'This is a sample result, it has been generated ⬇️',
'Submitted at': new Date().toISOString(),
...parseResultSample(linkedInputBlocks, header),
}
}
const extractLinkedInputBlocks =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
linkedTypebots: (Typebot | PublicTypebot)[]
) =>
async (
currentGroupId?: string,
direction: 'backward' | 'forward' = 'backward'
): Promise<InputBlock[]> => {
const previousLinkedTypebotBlocks = walkEdgesAndExtract(
'linkedBot',
direction,
typebot
)({
groupId: currentGroupId,
}) as TypebotLinkBlock[]
const linkedBotInputs =
previousLinkedTypebotBlocks.length > 0
? await Promise.all(
previousLinkedTypebotBlocks.map((linkedBot) =>
extractLinkedInputBlocks(
linkedTypebots.find((t) =>
'typebotId' in t
? t.typebotId === linkedBot.options.typebotId
: t.id === linkedBot.options.typebotId
) as Typebot | PublicTypebot,
linkedTypebots
)(linkedBot.options.groupId, 'forward')
)
)
: []
return (
walkEdgesAndExtract(
'input',
direction,
typebot
)({
groupId: currentGroupId,
}) as InputBlock[]
).concat(linkedBotInputs.flatMap((l) => l))
}
const parseResultSample = (
inputBlocks: InputBlock[],
headerCells: ResultHeaderCell[]
) =>
headerCells.reduce<Record<string, string | undefined>>(
(resultSample, cell) => {
const inputBlock = inputBlocks.find((inputBlock) =>
cell.blocks?.some((block) => block.id === inputBlock.id)
)
if (isNotDefined(inputBlock)) {
if (cell.variableIds)
return {
...resultSample,
[cell.label]: 'content',
}
return resultSample
}
const value = getSampleValue(inputBlock)
return {
...resultSample,
[cell.label]: value,
}
},
{}
)
const getSampleValue = (block: InputBlock) => {
switch (block.type) {
case InputBlockType.CHOICE:
return block.options.isMultipleChoice
? block.items.map((i) => i.content).join(', ')
: block.items[0]?.content ?? 'Item'
case InputBlockType.DATE:
return new Date().toUTCString()
case InputBlockType.EMAIL:
return 'test@email.com'
case InputBlockType.NUMBER:
return '20'
case InputBlockType.PHONE:
return '+33665566773'
case InputBlockType.TEXT:
return 'answer value'
case InputBlockType.URL:
return 'https://test.com'
}
}
const walkEdgesAndExtract =
(
type: 'input' | 'linkedBot',
direction: 'backward' | 'forward',
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>
) =>
({ groupId }: { groupId?: string }): Block[] => {
const currentGroupId =
groupId ??
(typebot.groups.find((b) => b.blocks[0].type === 'start')?.id as string)
const blocksInGroup = extractBlocksInGroup(
type,
typebot
)({
groupId: currentGroupId,
})
const otherGroupIds = getGroupIds(typebot, direction)(currentGroupId)
return [
...blocksInGroup,
...otherGroupIds.flatMap((groupId) =>
extractBlocksInGroup(type, typebot)({ groupId })
),
]
}
const getGroupIds =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
direction: 'backward' | 'forward',
existingGroupIds?: string[]
) =>
(groupId: string): string[] => {
const groups = typebot.edges.reduce<string[]>((groupIds, edge) => {
if (direction === 'forward')
return (!existingGroupIds ||
!existingGroupIds?.includes(edge.to.groupId)) &&
edge.from.groupId === groupId
? [...groupIds, edge.to.groupId]
: groupIds
return (!existingGroupIds ||
!existingGroupIds.includes(edge.from.groupId)) &&
edge.to.groupId === groupId
? [...groupIds, edge.from.groupId]
: groupIds
}, [])
const newGroups = [...(existingGroupIds ?? []), ...groups]
return groups.concat(
groups.flatMap(getGroupIds(typebot, direction, newGroups))
)
}
const extractBlocksInGroup =
(
type: 'input' | 'linkedBot',
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>
) =>
({ groupId, blockId }: { groupId: string; blockId?: string }) => {
const currentGroup = typebot.groups.find(byId(groupId))
if (!currentGroup) return []
const blocks: Block[] = []
for (const block of currentGroup.blocks) {
if (block.id === blockId) break
if (type === 'input' && isInputBlock(block)) blocks.push(block)
if (type === 'linkedBot' && block.type === LogicBlockType.TYPEBOT_LINK)
blocks.push(block)
}
return blocks
}

View File

@@ -6,8 +6,9 @@ import {
import { HttpMethod } from 'models'
import cuid from 'cuid'
import { getTestAsset } from '@/test/utils/playwright'
import { apiToken } from 'utils/playwright/databaseSetup'
test.describe('Webhook block', () => {
test.describe('Builder', () => {
test('easy configuration should work', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
@@ -98,3 +99,83 @@ const addTestVariable = async (page: Page, name: string, value: string) => {
await page.click(`text="${name}"`)
await page.fill('input >> nth=-1', value)
}
test.describe('API', () => {
const typebotId = 'webhook-flow'
test.beforeAll(async () => {
try {
await importTypebotInDatabase(getTestAsset('typebots/api.json'), {
id: typebotId,
})
await createWebhook(typebotId)
} catch (err) {
console.log(err)
}
})
test('can get webhook blocks', async ({ request }) => {
const response = await request.get(
`/api/v1/typebots/${typebotId}/webhookBlocks`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const { webhookBlocks } = await response.json()
expect(webhookBlocks).toHaveLength(1)
expect(webhookBlocks[0]).toEqual({
id: 'webhookBlock',
label: 'Webhook > webhookBlock',
})
})
test('can subscribe webhook', async ({ request }) => {
const url = 'https://test.com'
const response = await request.post(
`/api/v1/typebots/${typebotId}/webhookBlocks/webhookBlock/subscribe`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
data: { url },
}
)
const body = await response.json()
expect(body).toEqual({
id: 'webhookBlock',
url,
})
})
test('can unsubscribe webhook', async ({ request }) => {
const response = await request.post(
`/api/v1/typebots/${typebotId}/webhookBlocks/webhookBlock/unsubscribe`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const body = await response.json()
expect(body).toEqual({
id: 'webhookBlock',
url: null,
})
})
test('can get a sample result', async ({ request }) => {
const response = await request.get(
`/api/v1/typebots/${typebotId}/webhookBlocks/webhookBlock/getResultExample`,
{
headers: { Authorization: `Bearer ${apiToken}` },
}
)
const data = await response.json()
expect(data.resultExample).toMatchObject({
message: 'This is a sample result, it has been generated ⬇️',
Welcome: 'Hi!',
Email: 'test@email.com',
Name: 'answer value',
Services: 'Website dev, Content Marketing, Social Media, UI / UX Design',
'Additional information': 'answer value',
})
})
})