mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
Add Webhooks for Zapier Integration (#273)
* add simple webhooks * add pipelines endpoint to handle internal events
This commit is contained in:
57
apps/web/app/api/pipeline/route.ts
Normal file
57
apps/web/app/api/pipeline/route.ts
Normal 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: {} });
|
||||
}
|
||||
74
apps/web/app/api/v1/webhooks/[webhookId]/route.ts
Normal file
74
apps/web/app/api/v1/webhooks/[webhookId]/route.ts
Normal 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 });
|
||||
}
|
||||
82
apps/web/app/api/v1/webhooks/route.ts
Normal file
82
apps/web/app/api/v1/webhooks/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -60,6 +60,7 @@ const nextConfig = {
|
||||
},
|
||||
env: {
|
||||
INSTANCE_ID: createId(),
|
||||
INTERNAL_SECRET: createId(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,3 +13,6 @@ export const WEBAPP_URL =
|
||||
HEROKU_URL ||
|
||||
RENDER_URL ||
|
||||
"http://localhost:3000";
|
||||
|
||||
// Other
|
||||
export const INTERNAL_SECRET = process.env.INTERNAL_SECRET;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user