From 325eda064e079e5f23a9048d74de609174604d75 Mon Sep 17 00:00:00 2001 From: Matti Nannt Date: Fri, 5 May 2023 10:53:54 +0200 Subject: [PATCH] Add Webhooks for Zapier Integration (#273) * add simple webhooks * add pipelines endpoint to handle internal events --- apps/web/app/api/pipeline/route.ts | 57 +++++++++++++ .../app/api/v1/webhooks/[webhookId]/route.ts | 74 +++++++++++++++++ apps/web/app/api/v1/webhooks/route.ts | 82 +++++++++++++++++++ apps/web/next.config.js | 1 + .../responses/[responseId]/index.ts | 35 +++++++- .../[environmentId]/responses/index.ts | 38 ++++++++- .../20230505085230_add_webhooks/migration.sql | 26 ++++++ packages/database/prisma/schema.prisma | 18 +++- packages/lib/constants.ts | 3 + turbo.json | 1 + 10 files changed, 329 insertions(+), 6 deletions(-) create mode 100644 apps/web/app/api/pipeline/route.ts create mode 100644 apps/web/app/api/v1/webhooks/[webhookId]/route.ts create mode 100644 apps/web/app/api/v1/webhooks/route.ts create mode 100644 packages/database/prisma/migrations/20230505085230_add_webhooks/migration.sql diff --git a/apps/web/app/api/pipeline/route.ts b/apps/web/app/api/pipeline/route.ts new file mode 100644 index 0000000000..5f863659e0 --- /dev/null +++ b/apps/web/app/api/pipeline/route.ts @@ -0,0 +1,57 @@ +import { INTERNAL_SECRET } from "@formbricks/lib/constants"; +import { prisma } from "@formbricks/database"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + const { internalSecret, environmentId, event, data } = await request.json(); + if (!internalSecret) { + return new Response("Missing internalSecret", { + status: 400, + }); + } + if (!environmentId) { + return new Response("Missing environmentId", { + status: 400, + }); + } + if (!event) { + return new Response("Missing event", { + status: 400, + }); + } + if (!data) { + return new Response("Missing data", { + status: 400, + }); + } + if (internalSecret !== INTERNAL_SECRET) { + return new Response("Invalid internalSecret", { + status: 401, + }); + } + + // get all webhooks of this environment where event in triggers + const webhooks = await prisma.webhook.findMany({ + where: { + environmentId, + triggers: { + hasSome: event, + }, + }, + }); + + // send request to all webhooks + await Promise.all( + webhooks.map(async (webhook) => { + await fetch(webhook.url, { + method: "POST", + body: JSON.stringify({ + event, + data, + }), + }); + }) + ); + + return NextResponse.json({ data: {} }); +} diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/route.ts b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts new file mode 100644 index 0000000000..1d1a343a3e --- /dev/null +++ b/apps/web/app/api/v1/webhooks/[webhookId]/route.ts @@ -0,0 +1,74 @@ +import { headers } from "next/headers"; +import { prisma } from "@formbricks/database"; +import { NextResponse } from "next/server"; +import { hashApiKey } from "@/lib/api/apiHelper"; + +export async function GET(_: Request, { params }: { params: { webhookId: string } }) { + const apiKey = headers().get("x-api-key"); + if (!apiKey) { + return new Response("Not authenticated. This route is only available via API-Key authorization", { + status: 401, + }); + } + const apiKeyData = await prisma.apiKey.findUnique({ + where: { + hashedKey: hashApiKey(apiKey), + }, + select: { + environmentId: true, + }, + }); + if (!apiKeyData) { + return new Response("Not authenticated", { + status: 401, + }); + } + + // add webhook to database + const webhook = await prisma.webhook.findUnique({ + where: { + id: params.webhookId, + }, + }); + if (!webhook) { + return new Response("Webhook not found", { + status: 404, + }); + } + return NextResponse.json({ data: webhook }); +} + +export async function DELETE(_: Request, { params }: { params: { webhookId: string } }) { + const apiKey = headers().get("x-api-key"); + if (!apiKey) { + return new Response("Not authenticated. This route is only available via API-Key authorization", { + status: 401, + }); + } + const apiKeyData = await prisma.apiKey.findUnique({ + where: { + hashedKey: hashApiKey(apiKey), + }, + select: { + environmentId: true, + }, + }); + if (!apiKeyData) { + return new Response("Not authenticated", { + status: 401, + }); + } + + // add webhook to database + const webhook = await prisma.webhook.delete({ + where: { + id: params.webhookId, + }, + }); + if (!webhook) { + return new Response("Webhook not found", { + status: 404, + }); + } + return NextResponse.json({ data: webhook }); +} diff --git a/apps/web/app/api/v1/webhooks/route.ts b/apps/web/app/api/v1/webhooks/route.ts new file mode 100644 index 0000000000..ba8234f66b --- /dev/null +++ b/apps/web/app/api/v1/webhooks/route.ts @@ -0,0 +1,82 @@ +import { headers } from "next/headers"; +import { prisma } from "@formbricks/database"; +import { NextResponse } from "next/server"; +import { hashApiKey } from "@/lib/api/apiHelper"; + +export async function GET() { + const apiKey = headers().get("x-api-key"); + if (!apiKey) { + return new Response("Not authenticated. This route is only available via API-Key authorization", { + status: 401, + }); + } + const apiKeyData = await prisma.apiKey.findUnique({ + where: { + hashedKey: hashApiKey(apiKey), + }, + select: { + environmentId: true, + }, + }); + if (!apiKeyData) { + return new Response("Not authenticated", { + status: 401, + }); + } + + // add webhook to database + const webhooks = await prisma.webhook.findMany({ + where: { + environmentId: apiKeyData.environmentId, + }, + }); + return NextResponse.json({ data: webhooks }); +} + +export async function POST(request: Request) { + const apiKey = headers().get("x-api-key"); + if (!apiKey) { + return new Response("Not authenticated. This route is only available via API-Key authorization", { + status: 401, + }); + } + const apiKeyData = await prisma.apiKey.findUnique({ + where: { + hashedKey: hashApiKey(apiKey), + }, + select: { + environmentId: true, + }, + }); + if (!apiKeyData) { + return new Response("Not authenticated", { + status: 401, + }); + } + const { url, triggers } = await request.json(); + if (!url) { + return new Response("Missing url", { + status: 400, + }); + } + + if (!triggers || !triggers.length) { + return new Response("Missing triggers", { + status: 400, + }); + } + + // add webhook to database + const webhook = await prisma.webhook.create({ + data: { + url, + triggers, + environment: { + connect: { + id: apiKeyData.environmentId, + }, + }, + }, + }); + return NextResponse.json({ data: webhook }); +} diff --git a/apps/web/next.config.js b/apps/web/next.config.js index e721eab8fd..65ed64a9e2 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -60,6 +60,7 @@ const nextConfig = { }, env: { INSTANCE_ID: createId(), + INTERNAL_SECRET: createId(), }, }; diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts index 31fd2c96d4..0b585f3f46 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts @@ -1,3 +1,4 @@ +import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; import { prisma } from "@formbricks/database"; import type { NextApiRequest, NextApiResponse } from "next"; @@ -41,7 +42,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) ...response.data, }; - // create new response + // update response const responseData = await prisma.response.update({ where: { id: responseId, @@ -51,6 +52,38 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) }, }); + // send response update to pipeline + // don't await to not block the response + fetch(`${WEBAPP_URL}/api/pipeline`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + internalSecret: INTERNAL_SECRET, + environmentId, + event: "responseUpdated", + data: { id: responseId, ...response }, + }), + }); + + if (response.finished) { + // send response to pipeline + // don't await to not block the response + fetch(`${WEBAPP_URL}/api/pipeline`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + internalSecret: INTERNAL_SECRET, + environmentId, + event: "responseFinished", + data: responseData, + }), + }); + } + return res.json(responseData); } diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts index 3e520b3eb2..ed1c2bffcd 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts @@ -2,6 +2,7 @@ import { prisma } from "@formbricks/database"; import type { NextApiRequest, NextApiResponse } from "next"; import { captureTelemetry } from "@formbricks/lib/telemetry"; import { capturePosthogEvent } from "@formbricks/lib/posthogServer"; +import { INTERNAL_SECRET, WEBAPP_URL } from "@formbricks/lib/constants"; export default async function handle(req: NextApiRequest, res: NextApiResponse) { const environmentId = req.query.environmentId?.toString(); @@ -75,9 +76,6 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) const teamOwnerId = environment.product.team.memberships.find((m) => m.role === "owner")?.userId; const createBody = { - select: { - id: true, - }, data: { survey: { connect: { @@ -99,6 +97,38 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) // create new response const responseData = await prisma.response.create(createBody); + // send response to pipeline + // don't await to not block the response + fetch(`${WEBAPP_URL}/api/pipeline`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + internalSecret: INTERNAL_SECRET, + environmentId, + event: "responseCreated", + data: responseData, + }), + }); + + if (response.finished) { + // send response to pipeline + // don't await to not block the response + fetch(`${WEBAPP_URL}/api/pipeline`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + internalSecret: INTERNAL_SECRET, + environmentId, + event: "responseFinished", + data: responseData, + }), + }); + } + captureTelemetry("response created"); if (teamOwnerId) { await capturePosthogEvent(teamOwnerId, "response created", teamId, { @@ -109,7 +139,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) console.warn("Posthog capture not possible. No team owner found"); } - return res.json(responseData); + return res.json({ id: responseData.id }); } // Unknown HTTP Method diff --git a/packages/database/prisma/migrations/20230505085230_add_webhooks/migration.sql b/packages/database/prisma/migrations/20230505085230_add_webhooks/migration.sql new file mode 100644 index 0000000000..2cbed57746 --- /dev/null +++ b/packages/database/prisma/migrations/20230505085230_add_webhooks/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - You are about to drop the column `tags` on the `Response` table. All the data in the column will be lost. + +*/ +-- CreateEnum +CREATE TYPE "PipelineTriggers" AS ENUM ('responseCreated', 'responseUpdated', 'responseFinished'); + +-- AlterTable +ALTER TABLE "Response" DROP COLUMN "tags"; + +-- CreateTable +CREATE TABLE "Webhook" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "url" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + "triggers" "PipelineTriggers"[], + + CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index c52f8d49a5..2cf8798d18 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -12,6 +12,22 @@ generator client { //provider = "prisma-dbml-generator" } +enum PipelineTriggers { + responseCreated + responseUpdated + responseFinished +} + +model Webhook { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + url String + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + environmentId String + triggers PipelineTriggers[] +} + model Attribute { id String @id @default(cuid()) createdAt DateTime @default(now()) @map(name: "created_at") @@ -69,7 +85,6 @@ model Response { data Json @default("{}") meta Json @default("{}") userAttributes Json @default("[]") - tags String[] } enum SurveyStatus { @@ -198,6 +213,7 @@ model Environment { eventClasses EventClass[] attributeClasses AttributeClass[] apiKeys ApiKey[] + webhooks Webhook[] } model Product { diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index f204f268fe..2f4d0fe206 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -13,3 +13,6 @@ export const WEBAPP_URL = HEROKU_URL || RENDER_URL || "http://localhost:3000"; + +// Other +export const INTERNAL_SECRET = process.env.INTERNAL_SECRET; diff --git a/turbo.json b/turbo.json index f9f824d124..0855ff3ff4 100644 --- a/turbo.json +++ b/turbo.json @@ -10,6 +10,7 @@ "GITHUB_SECRET", "HEROKU_APP_NAME", "INSTANCE_ID", + "INTERNAL_SECRET", "MAIL_FROM", "NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED", "NEXT_PUBLIC_GITHUB_AUTH_ENABLED",