chore: adds webhook types (#4606)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
This commit is contained in:
Piyush Gupta
2025-01-24 17:53:07 +05:30
committed by GitHub
parent ad842e0e80
commit e691c076a1
35 changed files with 481 additions and 370 deletions

View File

@@ -0,0 +1,35 @@
import { webhookCache } from "@/lib/cache/webhook";
import { Prisma, Webhook } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
export const getWebhookCountBySource = (environmentId: string, source?: Webhook["source"]): Promise<number> =>
cache(
async () => {
validateInputs([environmentId, ZId], [source, z.string().optional()]);
try {
const count = await prisma.webhook.count({
where: {
environmentId,
source,
},
});
return count;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getWebhookCountBySource-${environmentId}-${source}`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, source)],
}
)();

View File

@@ -1,3 +1,4 @@
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/integrations/lib/webhook";
import AirtableLogo from "@/images/airtableLogo.svg";
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
import JsLogo from "@/images/jslogo.png";
@@ -22,7 +23,6 @@ import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getWebhookCountBySource } from "@formbricks/lib/webhook/service";
import { TIntegrationType } from "@formbricks/types/integration";
const Page = async (props) => {

View File

@@ -1,76 +1,3 @@
import { AddWebhookButton } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/AddWebhookButton";
import { WebhookRowData } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookRowData";
import { WebhookTable } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookTable";
import { WebhookTableHeading } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookTableHeading";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { getWebhooks } from "@formbricks/lib/webhook/service";
import { WebhooksPage } from "@/modules/integrations/webhooks/page";
const Page = async (props) => {
const params = await props.params;
const t = await getTranslations();
const [session, organization, webhooksUnsorted, surveys, environment] = await Promise.all([
getServerSession(authOptions),
getOrganizationByEnvironmentId(params.environmentId),
getWebhooks(params.environmentId),
getSurveys(params.environmentId, 200), // HOTFIX: not getting all surveys for now since it's maxing out the prisma accelerate limit
getEnvironment(params.environmentId),
]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
const webhooks = webhooksUnsorted.sort((a, b) => {
if (a.createdAt > b.createdAt) return -1;
if (a.createdAt < b.createdAt) return 1;
return 0;
});
const renderAddWebhookButton = () => <AddWebhookButton environment={environment} surveys={surveys} />;
const locale = await findMatchingLocale();
return (
<PageContentWrapper>
<GoBackButton />
<PageHeader pageTitle={t("common.webhooks")} cta={!isReadOnly ? renderAddWebhookButton() : <></>} />
<WebhookTable environment={environment} webhooks={webhooks} surveys={surveys} isReadOnly={isReadOnly}>
<WebhookTableHeading />
{webhooks.map((webhook) => (
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} locale={locale} />
))}
</WebhookTable>
</PageContentWrapper>
);
};
export default Page;
export default WebhooksPage;

View File

@@ -1,3 +1,4 @@
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { writeData as airtableWriteData } from "@formbricks/lib/airtable/service";
import { writeData } from "@formbricks/lib/googleSheet/service";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
@@ -14,7 +15,6 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { TPipelineInput } from "@formbricks/types/pipelines";
import { TResponseMeta } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";

View File

@@ -1,10 +1,13 @@
import { createDocumentAndAssignInsight } from "@/app/api/(internal)/pipeline/lib/documents";
import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { webhookCache } from "@/lib/cache/webhook";
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
import { sendResponseFinishedEmail } from "@/modules/email";
import { getSurveyFollowUpsPermission } from "@/modules/survey-follow-ups/lib/utils";
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
@@ -16,9 +19,6 @@ import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { convertDatesInObject } from "@formbricks/lib/time";
import { getPromptText } from "@formbricks/lib/utils/ai";
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
import { webhookCache } from "@formbricks/lib/webhook/cache";
import { TPipelineTrigger, ZPipelineInput } from "@formbricks/types/pipelines";
import { TWebhook } from "@formbricks/types/webhooks";
import { getContactAttributes } from "./lib/contact-attribute";
import { handleIntegrations } from "./lib/handleIntegrations";
@@ -53,7 +53,7 @@ export const POST = async (request: Request) => {
// Fetch webhooks
const getWebhooksForPipeline = cache(
async (environmentId: string, event: TPipelineTrigger, surveyId: string) => {
async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
const webhooks = await prisma.webhook.findMany({
where: {
environmentId,
@@ -68,7 +68,7 @@ export const POST = async (request: Request) => {
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
}
);
const webhooks: TWebhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
// Prepare webhook and email promises
// Fetch with timeout of 5 seconds to prevent hanging

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
import { ZResponse } from "@formbricks/types/responses";
export const ZPipelineInput = z.object({
event: ZWebhook.shape.triggers.element,
response: ZResponse,
environmentId: z.string(),
surveyId: z.string(),
});
export type TPipelineInput = z.infer<typeof ZPipelineInput>;

View File

@@ -0,0 +1,59 @@
import { webhookCache } from "@/lib/cache/webhook";
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
export const deleteWebhook = async (id: string): Promise<Webhook> => {
validateInputs([id, ZId]);
try {
let deletedWebhook = await prisma.webhook.delete({
where: {
id,
},
});
webhookCache.revalidate({
id: deletedWebhook.id,
environmentId: deletedWebhook.environmentId,
source: deletedWebhook.source,
});
return deletedWebhook;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
throw new ResourceNotFoundError("Webhook", id);
}
throw new DatabaseError(`Database error when deleting webhook with ID ${id}`);
}
};
export const getWebhook = async (id: string): Promise<Webhook | null> =>
cache(
async () => {
validateInputs([id, ZId]);
try {
const webhook = await prisma.webhook.findUnique({
where: {
id,
},
});
return webhook;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getWebhook-${id}`],
{
tags: [webhookCache.tag.byId(id)],
}
)();

View File

@@ -1,7 +1,7 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
import { responses } from "@/app/lib/api/response";
import { headers } from "next/headers";
import { deleteWebhook, getWebhook } from "@formbricks/lib/webhook/service";
export const GET = async (_: Request, props: { params: Promise<{ webhookId: string }> }) => {
const params = await props.params;

View File

@@ -0,0 +1,73 @@
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { webhookCache } from "@/lib/cache/webhook";
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
validateInputs([environmentId, ZId], [webhookInput, ZWebhookInput]);
try {
const createdWebhook = await prisma.webhook.create({
data: {
...webhookInput,
surveyIds: webhookInput.surveyIds || [],
environment: {
connect: {
id: environmentId,
},
},
},
});
webhookCache.revalidate({
id: createdWebhook.id,
environmentId: createdWebhook.environmentId,
source: createdWebhook.source,
});
return createdWebhook;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
if (!(error instanceof InvalidInputError)) {
throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`);
}
throw error;
}
};
export const getWebhooks = (environmentId: string, page?: number): Promise<Webhook[]> =>
cache(
async () => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const webhooks = await prisma.webhook.findMany({
where: {
environmentId: environmentId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return webhooks;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getWebhooks-${environmentId}-${page}`],
{
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
}
)();

View File

@@ -1,10 +1,10 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { headers } from "next/headers";
import { createWebhook, getWebhooks } from "@formbricks/lib/webhook/service";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { ZWebhookInput } from "@formbricks/types/webhooks";
export const GET = async () => {
const headersList = await headers();

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
export const ZWebhookInput = ZWebhook.partial({
name: true,
source: true,
surveyIds: true,
}).pick({
name: true,
source: true,
surveyIds: true,
triggers: true,
url: true,
});
export type TWebhookInput = z.infer<typeof ZWebhookInput>;

View File

@@ -1,5 +1,5 @@
import { TPipelineInput } from "@/app/lib/types/pipelines";
import { CRON_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { TPipelineInput } from "@formbricks/types/pipelines";
export const sendToPipeline = async ({ event, surveyId, environmentId, response }: TPipelineInput) => {
return fetch(`${WEBAPP_URL}/api/pipeline`, {

View File

@@ -0,0 +1,9 @@
import { PipelineTriggers } from "@prisma/client";
import { TResponse } from "@formbricks/types/responses";
export interface TPipelineInput {
event: PipelineTriggers;
response: TResponse;
environmentId: string;
surveyId: string;
}

View File

@@ -1,10 +1,10 @@
import { Webhook } from "@prisma/client";
import { revalidateTag } from "next/cache";
import { TWebhookInput } from "@formbricks/types/webhooks";
interface RevalidateProps {
id?: string;
environmentId?: string;
source?: TWebhookInput["source"];
source?: Webhook["source"];
}
export const webhookCache = {
@@ -15,7 +15,7 @@ export const webhookCache = {
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-webhooks`;
},
byEnvironmentIdAndSource(environmentId: string, source: TWebhookInput["source"]) {
byEnvironmentIdAndSource(environmentId: string, source?: Webhook["source"]) {
return `environments-${environmentId}-sources-${source}-webhooks`;
},
},

View File

@@ -3,6 +3,7 @@
import { apiKeyCache } from "@/lib/cache/api-key";
import { contactCache } from "@/lib/cache/contact";
import { teamCache } from "@/lib/cache/team";
import { webhookCache } from "@/lib/cache/webhook";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
@@ -18,7 +19,6 @@ import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { tagCache } from "@formbricks/lib/tag/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { webhookCache } from "@formbricks/lib/webhook/cache";
import { ZId, ZString } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";

View File

@@ -8,11 +8,15 @@ import {
getProjectIdFromEnvironmentId,
getProjectIdFromWebhookId,
} from "@/lib/utils/helper";
import {
createWebhook,
deleteWebhook,
testEndpoint,
updateWebhook,
} from "@/modules/integrations/webhooks/lib/webhook";
import { ZWebhookInput } from "@/modules/integrations/webhooks/types/webhooks";
import { z } from "zod";
import { createWebhook, deleteWebhook, updateWebhook } from "@formbricks/lib/webhook/service";
import { testEndpoint } from "@formbricks/lib/webhook/utils";
import { ZId } from "@formbricks/types/common";
import { ZWebhookInput } from "@formbricks/types/webhooks";
const ZCreateWebhookAction = z.object({
environmentId: ZId,

View File

@@ -6,7 +6,7 @@ import { useTranslations } from "next-intl";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { AddWebhookModal } from "./AddWebhookModal";
import { AddWebhookModal } from "./add-webhook-modal";
interface AddWebhookButtonProps {
environment: TEnvironment;

View File

@@ -1,11 +1,12 @@
import { SurveyCheckboxGroup } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/SurveyCheckboxGroup";
import { TriggerCheckboxGroup } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/TriggerCheckboxGroup";
import { validWebHookURL } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/lib/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { SurveyCheckboxGroup } from "@/modules/integrations/webhooks/components/survey-checkbox-group";
import { TriggerCheckboxGroup } from "@/modules/integrations/webhooks/components/trigger-checkbox-group";
import { validWebHookURL } from "@/modules/integrations/webhooks/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { PipelineTriggers } from "@prisma/client";
import clsx from "clsx";
import { Webhook } from "lucide-react";
import { useTranslations } from "next-intl";
@@ -13,10 +14,9 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TPipelineTrigger } from "@formbricks/types/pipelines";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TWebhookInput } from "@formbricks/types/webhooks";
import { createWebhookAction, testEndpointAction } from "../actions";
import { TWebhookInput } from "../types/webhooks";
interface AddWebhookModalProps {
environmentId: string;
@@ -37,7 +37,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
const [testEndpointInput, setTestEndpointInput] = useState("");
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
const [selectedTriggers, setSelectedTriggers] = useState<TPipelineTrigger[]>([]);
const [selectedTriggers, setSelectedTriggers] = useState<PipelineTriggers[]>([]);
const [selectedSurveys, setSelectedSurveys] = useState<string[]>([]);
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
const [creatingWebhook, setCreatingWebhook] = useState(false);
@@ -88,7 +88,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
);
};
const handleCheckboxChange = (selectedValue: TPipelineTrigger) => {
const handleCheckboxChange = (selectedValue: PipelineTriggers) => {
setSelectedTriggers((prevValues) =>
prevValues.includes(selectedValue)
? prevValues.filter((value) => value !== selectedValue)

View File

@@ -1,26 +1,29 @@
import { Checkbox } from "@/modules/ui/components/checkbox";
import { PipelineTriggers } from "@prisma/client";
import { useTranslations } from "next-intl";
import React from "react";
import { TPipelineTrigger } from "@formbricks/types/pipelines";
interface TriggerCheckboxGroupProps {
selectedTriggers: TPipelineTrigger[];
onCheckboxChange: (selectedValue: TPipelineTrigger) => void;
selectedTriggers: PipelineTriggers[];
onCheckboxChange: (selectedValue: PipelineTriggers) => void;
allowChanges: boolean;
}
const triggers = [
const triggers: {
title: string;
value: PipelineTriggers;
}[] = [
{
title: "environments.integrations.webhooks.response_created",
value: "responseCreated" as TPipelineTrigger,
value: "responseCreated",
},
{
title: "environments.integrations.webhooks.response_updated",
value: "responseUpdated" as TPipelineTrigger,
value: "responseUpdated",
},
{
title: "environments.integrations.webhooks.response_finished",
value: "responseFinished" as TPipelineTrigger,
value: "responseFinished",
},
];

View File

@@ -1,15 +1,15 @@
import { WebhookOverviewTab } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookOverviewTab";
import { WebhookSettingsTab } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookSettingsTab";
import { WebhookOverviewTab } from "@/modules/integrations/webhooks/components/webhook-overview-tab";
import { WebhookSettingsTab } from "@/modules/integrations/webhooks/components/webhook-settings-tab";
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
import { Webhook } from "lucide-react";
import { Webhook } from "@prisma/client";
import { WebhookIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TWebhook } from "@formbricks/types/webhooks";
interface WebhookModalProps {
open: boolean;
setOpen: (v: boolean) => void;
webhook: TWebhook;
webhook: Webhook;
surveys: TSurvey[];
isReadOnly: boolean;
}
@@ -35,7 +35,7 @@ export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: We
open={open}
setOpen={setOpen}
tabs={tabs}
icon={<Webhook />}
icon={<WebhookIcon />}
label={webhook.name ? webhook.name : webhook.url}
description={""}
/>

View File

@@ -1,16 +1,16 @@
import { Label } from "@/modules/ui/components/label";
import { Webhook } from "@prisma/client";
import { useTranslations } from "next-intl";
import { convertDateTimeStringShort } from "@formbricks/lib/time";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TWebhook } from "@formbricks/types/webhooks";
interface ActivityTabProps {
webhook: TWebhook;
webhook: Webhook;
surveys: TSurvey[];
}
const getSurveyNamesForWebhook = (webhook: TWebhook, allSurveys: TSurvey[]): string[] => {
const getSurveyNamesForWebhook = (webhook: Webhook, allSurveys: TSurvey[]): string[] => {
if (webhook.surveyIds.length === 0) {
return allSurveys.map((survey) => survey.name);
} else {

View File

@@ -1,12 +1,12 @@
import { Badge } from "@/modules/ui/components/badge";
import { Webhook } from "@prisma/client";
import { useTranslations } from "next-intl";
import { timeSince } from "@formbricks/lib/time";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { TWebhook } from "@formbricks/types/webhooks";
const renderSelectedSurveysText = (webhook: TWebhook, allSurveys: TSurvey[]) => {
const renderSelectedSurveysText = (webhook: Webhook, allSurveys: TSurvey[]) => {
if (webhook.surveyIds.length === 0) {
const allSurveyNames = allSurveys.map((survey) => survey.name);
return <p className="text-slate-400">{allSurveyNames.join(", ")}</p>;
@@ -19,7 +19,7 @@ const renderSelectedSurveysText = (webhook: TWebhook, allSurveys: TSurvey[]) =>
}
};
const renderSelectedTriggersText = (webhook: TWebhook, t: (key: string) => string) => {
const renderSelectedTriggersText = (webhook: Webhook, t: (key: string) => string) => {
if (webhook.triggers.length === 0) {
return <p className="text-slate-400">No Triggers</p>;
} else {
@@ -58,7 +58,7 @@ export const WebhookRowData = ({
surveys,
locale,
}: {
webhook: TWebhook;
webhook: Webhook;
surveys: TSurvey[];
locale: TUserLocale;
}) => {

View File

@@ -1,13 +1,14 @@
"use client";
import { SurveyCheckboxGroup } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/SurveyCheckboxGroup";
import { TriggerCheckboxGroup } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/TriggerCheckboxGroup";
import { validWebHookURL } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/lib/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { SurveyCheckboxGroup } from "@/modules/integrations/webhooks/components/survey-checkbox-group";
import { TriggerCheckboxGroup } from "@/modules/integrations/webhooks/components/trigger-checkbox-group";
import { validWebHookURL } from "@/modules/integrations/webhooks/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { PipelineTriggers, Webhook } from "@prisma/client";
import clsx from "clsx";
import { TrashIcon } from "lucide-react";
import { useTranslations } from "next-intl";
@@ -16,13 +17,12 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TPipelineTrigger } from "@formbricks/types/pipelines";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TWebhook, TWebhookInput } from "@formbricks/types/webhooks";
import { deleteWebhookAction, testEndpointAction, updateWebhookAction } from "../actions";
import { TWebhookInput } from "../types/webhooks";
interface ActionSettingsTabProps {
webhook: TWebhook;
webhook: Webhook;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
isReadOnly: boolean;
@@ -42,7 +42,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: Ac
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
const [isUpdatingWebhook, setIsUpdatingWebhook] = useState(false);
const [selectedTriggers, setSelectedTriggers] = useState<TPipelineTrigger[]>(webhook.triggers);
const [selectedTriggers, setSelectedTriggers] = useState<PipelineTriggers[]>(webhook.triggers);
const [selectedSurveys, setSelectedSurveys] = useState<string[]>(webhook.surveyIds);
const [testEndpointInput, setTestEndpointInput] = useState(webhook.url);
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();

View File

@@ -1,16 +1,16 @@
"use client";
import { WebhookModal } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/WebhookDetailModal";
import { WebhookModal } from "@/modules/integrations/webhooks/components/webhook-detail-modal";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { Webhook } from "@prisma/client";
import { useTranslations } from "next-intl";
import { type JSX, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TWebhook } from "@formbricks/types/webhooks";
interface WebhookTableProps {
environment: TEnvironment;
webhooks: TWebhook[];
webhooks: Webhook[];
surveys: TSurvey[];
children: [JSX.Element, JSX.Element[]];
isReadOnly: boolean;
@@ -25,7 +25,7 @@ export const WebhookTable = ({
}: WebhookTableProps) => {
const [isWebhookDetailModalOpen, setWebhookDetailModalOpen] = useState(false);
const t = useTranslations();
const [activeWebhook, setActiveWebhook] = useState<TWebhook>({
const [activeWebhook, setActiveWebhook] = useState<Webhook>({
environmentId: environment.id,
id: "",
name: "",
@@ -37,7 +37,7 @@ export const WebhookTable = ({
updatedAt: new Date(),
});
const handleOpenWebhookDetailModalClick = (e, webhook: TWebhook) => {
const handleOpenWebhookDetailModalClick = (e, webhook: Webhook) => {
e.preventDefault();
setActiveWebhook(webhook);
setWebhookDetailModalOpen(true);

View File

@@ -1,143 +1,21 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { webhookCache } from "@/lib/cache/webhook";
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZOptionalNumber } from "@formbricks/types/common";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TWebhook, TWebhookInput, ZWebhookInput } from "@formbricks/types/webhooks";
import { cache } from "../cache";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
import { webhookCache } from "./cache";
export const getWebhooks = (environmentId: string, page?: number): Promise<TWebhook[]> =>
cache(
async () => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const webhooks = await prisma.webhook.findMany({
where: {
environmentId: environmentId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return webhooks;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getWebhooks-${environmentId}-${page}`],
{
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
}
)();
export const getWebhookCountBySource = (
environmentId: string,
source: TWebhookInput["source"]
): Promise<number> =>
cache(
async () => {
validateInputs([environmentId, ZId], [source, ZId]);
try {
const count = await prisma.webhook.count({
where: {
environmentId,
source,
},
});
return count;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getWebhookCountBySource-${environmentId}-${source}`],
{
tags: [webhookCache.tag.byEnvironmentIdAndSource(environmentId, source)],
}
)();
export const getWebhook = async (id: string): Promise<TWebhook | null> =>
cache(
async () => {
validateInputs([id, ZId]);
try {
const webhook = await prisma.webhook.findUnique({
where: {
id,
},
});
return webhook;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getWebhook-${id}`],
{
tags: [webhookCache.tag.byId(id)],
}
)();
export const createWebhook = async (
environmentId: string,
webhookInput: TWebhookInput
): Promise<TWebhook> => {
validateInputs([environmentId, ZId], [webhookInput, ZWebhookInput]);
try {
const createdWebhook = await prisma.webhook.create({
data: {
...webhookInput,
surveyIds: webhookInput.surveyIds || [],
environment: {
connect: {
id: environmentId,
},
},
},
});
webhookCache.revalidate({
id: createdWebhook.id,
environmentId: createdWebhook.environmentId,
source: createdWebhook.source,
});
return createdWebhook;
} catch (error) {
if (!(error instanceof InvalidInputError)) {
throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`);
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UnknownError,
} from "@formbricks/types/errors";
import { TWebhookInput } from "../types/webhooks";
export const updateWebhook = async (
webhookId: string,
webhookInput: Partial<TWebhookInput>
): Promise<TWebhook> => {
validateInputs([webhookId, ZId], [webhookInput, ZWebhookInput]);
): Promise<boolean> => {
try {
const updatedWebhook = await prisma.webhook.update({
where: {
@@ -157,7 +35,7 @@ export const updateWebhook = async (
source: updatedWebhook.source,
});
return updatedWebhook;
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -167,9 +45,7 @@ export const updateWebhook = async (
}
};
export const deleteWebhook = async (id: string): Promise<TWebhook> => {
validateInputs([id, ZId]);
export const deleteWebhook = async (id: string): Promise<boolean> => {
try {
let deletedWebhook = await prisma.webhook.delete({
where: {
@@ -183,7 +59,7 @@ export const deleteWebhook = async (id: string): Promise<TWebhook> => {
source: deletedWebhook.source,
});
return deletedWebhook;
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
throw new ResourceNotFoundError("Webhook", id);
@@ -191,3 +67,100 @@ export const deleteWebhook = async (id: string): Promise<TWebhook> => {
throw new DatabaseError(`Database error when deleting webhook with ID ${id}`);
}
};
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<boolean> => {
try {
const createdWebhook = await prisma.webhook.create({
data: {
...webhookInput,
surveyIds: webhookInput.surveyIds || [],
environment: {
connect: {
id: environmentId,
},
},
},
});
webhookCache.revalidate({
id: createdWebhook.id,
environmentId: createdWebhook.environmentId,
source: createdWebhook.source,
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
if (!(error instanceof InvalidInputError)) {
throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`);
}
throw error;
}
};
export const getWebhooks = (environmentId: string): Promise<Webhook[]> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
try {
const webhooks = await prisma.webhook.findMany({
where: {
environmentId: environmentId,
},
orderBy: {
createdAt: "desc",
},
});
return webhooks;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getWebhooks-${environmentId}`],
{
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
}
)();
export const testEndpoint = async (url: string): Promise<boolean> => {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, {
method: "POST",
body: JSON.stringify({
event: "testEndpoint",
}),
headers: {
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeout);
const statusCode = response.status;
if (statusCode >= 200 && statusCode < 300) {
return true;
} else {
const errorMessage = await response.text().then(
(text) => text.substring(0, 1000) // Limit error message size
);
throw new UnknownError(`Request failed with status code ${statusCode}: ${errorMessage}`);
}
} catch (error) {
if (error.name === "AbortError") {
throw new UnknownError("Request timed out after 5 seconds");
}
throw new UnknownError(`Error while fetching the URL: ${error.message}`);
}
};

View File

@@ -0,0 +1,68 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { AddWebhookButton } from "@/modules/integrations/webhooks/components/add-webhook-button";
import { WebhookRowData } from "@/modules/integrations/webhooks/components/webhook-row-data";
import { WebhookTable } from "@/modules/integrations/webhooks/components/webhook-table";
import { WebhookTableHeading } from "@/modules/integrations/webhooks/components/webhook-table-heading";
import { getWebhooks } from "@/modules/integrations/webhooks/lib/webhook";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
export const WebhooksPage = async (props) => {
const params = await props.params;
const t = await getTranslations();
const [session, organization, webhooks, surveys, environment] = await Promise.all([
getServerSession(authOptions),
getOrganizationByEnvironmentId(params.environmentId),
getWebhooks(params.environmentId),
getSurveys(params.environmentId, 200), // HOTFIX: not getting all surveys for now since it's maxing out the prisma accelerate limit
getEnvironment(params.environmentId),
]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
const renderAddWebhookButton = () => <AddWebhookButton environment={environment} surveys={surveys} />;
const locale = await findMatchingLocale();
return (
<PageContentWrapper>
<GoBackButton />
<PageHeader pageTitle={t("common.webhooks")} cta={!isReadOnly ? renderAddWebhookButton() : <></>} />
<WebhookTable environment={environment} webhooks={webhooks} surveys={surveys} isReadOnly={isReadOnly}>
<WebhookTableHeading />
{webhooks.map((webhook) => (
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} locale={locale} />
))}
</WebhookTable>
</PageContentWrapper>
);
};

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
export const ZWebhookInput = ZWebhook.partial({
name: true,
source: true,
surveyIds: true,
}).pick({
name: true,
source: true,
surveyIds: true,
triggers: true,
url: true,
});
export type TWebhookInput = z.infer<typeof ZWebhookInput>;

View File

@@ -0,0 +1,14 @@
import type { Webhook } from "@prisma/client";
import { z } from "zod";
export const ZWebhook = z.object({
id: z.string().cuid2(),
name: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
url: z.string().url(),
source: z.enum(["user", "zapier", "make", "n8n"]),
environmentId: z.string().cuid2(),
triggers: z.array(z.enum(["responseFinished", "responseCreated", "responseUpdated"])),
surveyIds: z.array(z.string().cuid2()),
}) satisfies z.ZodType<Webhook>;

View File

@@ -1,29 +0,0 @@
import { ZId } from "@formbricks/types/common";
import { cache } from "../cache";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { validateInputs } from "../utils/validate";
import { webhookCache } from "./cache";
import { getWebhook } from "./service";
export const canUserAccessWebhook = async (userId: string, webhookId: string): Promise<boolean> =>
cache(
async () => {
validateInputs([userId, ZId], [webhookId, ZId]);
try {
const webhook = await getWebhook(webhookId);
if (!webhook) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, webhook.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
} catch (error) {
throw error;
}
},
[`canUserAccessWebhook-${userId}-${webhookId}`],
{
tags: [webhookCache.tag.byId(webhookId)],
}
)();

View File

@@ -1,26 +0,0 @@
import "server-only";
import { UnknownError } from "@formbricks/types/errors";
export const testEndpoint = async (url: string): Promise<boolean> => {
try {
const response = await fetch(url, {
method: "POST",
body: JSON.stringify({
event: "testEndpoint",
}),
headers: {
"Content-Type": "application/json",
},
});
const statusCode = response.status;
if (statusCode >= 200 && statusCode < 300) {
return true;
} else {
const errorMessage = await response.text();
throw new UnknownError(`Request failed with status code ${statusCode}: ${errorMessage}`);
}
} catch (error) {
throw new UnknownError(`Error while fetching the URL: ${error.message}`);
}
};

View File

@@ -1,15 +0,0 @@
import { z } from "zod";
import { ZResponse } from "./responses";
export const ZPipelineTrigger = z.enum(["responseFinished", "responseCreated", "responseUpdated"]);
export type TPipelineTrigger = z.infer<typeof ZPipelineTrigger>;
export const ZPipelineInput = z.object({
event: ZPipelineTrigger,
response: ZResponse,
environmentId: z.string(),
surveyId: z.string(),
});
export type TPipelineInput = z.infer<typeof ZPipelineInput>;

View File

@@ -1,28 +0,0 @@
import { z } from "zod";
import { ZPipelineTrigger } from "./pipelines";
export const ZWebhookSource = z.enum(["user", "zapier", "make", "n8n"]);
export const ZWebhook = z.object({
id: z.string().cuid2(),
name: z.string().nullish(),
createdAt: z.date(),
updatedAt: z.date(),
url: z.string().url(),
source: ZWebhookSource,
environmentId: z.string().cuid2(),
triggers: z.array(ZPipelineTrigger),
surveyIds: z.array(z.string().cuid2()),
});
export type TWebhook = z.infer<typeof ZWebhook>;
export const ZWebhookInput = z.object({
url: z.string().url(),
name: z.string().nullish(),
triggers: z.array(ZPipelineTrigger),
source: ZWebhookSource.optional(),
surveyIds: z.array(z.string().cuid2()).optional(),
});
export type TWebhookInput = z.infer<typeof ZWebhookInput>;