✨ Add webhook blocks API public endpoints
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from './router'
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './getResultExampleProcedure'
|
||||
export * from './listWebhookBlocksProcedure'
|
||||
export * from './subscribeWebhookProcedure'
|
||||
export * from './unsubscribeWebhookProcedure'
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from './parseResultExample'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user