Add Webhooks for Zapier Integration (#273)

* add simple webhooks

* add pipelines endpoint to handle internal events
This commit is contained in:
Matti Nannt
2023-05-05 10:53:54 +02:00
committed by GitHub
parent eecb10e255
commit 325eda064e
10 changed files with 329 additions and 6 deletions

View File

@@ -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: {} });
}

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -60,6 +60,7 @@ const nextConfig = {
},
env: {
INSTANCE_ID: createId(),
INTERNAL_SECRET: createId(),
},
};

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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 {

View File

@@ -13,3 +13,6 @@ export const WEBAPP_URL =
HEROKU_URL ||
RENDER_URL ||
"http://localhost:3000";
// Other
export const INTERNAL_SECRET = process.env.INTERNAL_SECRET;

View File

@@ -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",