mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-23 13:48:58 -05:00
feat: Added Webhooks in Management API V2 (#4949)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
@@ -43,7 +43,7 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
}): Promise<Response> => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication.ok) throw authentication.error;
|
||||
if (!authentication.ok) return handleApiError(request, authentication.error);
|
||||
|
||||
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
|
||||
|
||||
@@ -53,7 +53,7 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
|
||||
if (!bodyResult.success) {
|
||||
throw err({
|
||||
type: "forbidden",
|
||||
type: "bad_request",
|
||||
details: formatZodError(bodyResult.error),
|
||||
});
|
||||
}
|
||||
@@ -100,6 +100,6 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
request,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleApiError(request, err);
|
||||
return handleApiError(request, err.error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export const authenticateRequest = async (
|
||||
request: Request
|
||||
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
|
||||
if (apiKey) {
|
||||
const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentIdResult.ok) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { fetchEnvironmentId } from "@/modules/api/v2/management/lib/services";
|
||||
import {
|
||||
fetchEnvironmentId,
|
||||
fetchEnvironmentIdFromSurveyIds,
|
||||
} from "@/modules/api/v2/management/lib/services";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Result, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
@@ -14,3 +17,31 @@ export const getEnvironmentId = async (
|
||||
|
||||
return ok(result.data.environmentId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that all surveys are in the same environment and return the environment id
|
||||
* @param surveyIds array of survey ids from the same environment
|
||||
* @returns the common environment id
|
||||
*/
|
||||
export const getEnvironmentIdFromSurveyIds = async (
|
||||
surveyIds: string[]
|
||||
): Promise<Result<string, ApiErrorResponseV2>> => {
|
||||
const result = await fetchEnvironmentIdFromSurveyIds(surveyIds);
|
||||
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check if all items in the array are the same
|
||||
if (new Set(result.data).size !== 1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
type: "bad_request",
|
||||
details: [{ field: "surveyIds", issue: "not all surveys are in the same environment" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return ok(result.data[0]);
|
||||
};
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import {
|
||||
deleteResponseEndpoint,
|
||||
getResponseEndpoint,
|
||||
updateResponseEndpoint,
|
||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
|
||||
import {
|
||||
createResponseEndpoint,
|
||||
getResponsesEndpoint,
|
||||
} from "@/modules/api/v2/management/responses/lib/openapi";
|
||||
import { ZodOpenApiPathsObject } from "zod-openapi";
|
||||
|
||||
export const responsePaths: ZodOpenApiPathsObject = {
|
||||
"/responses": {
|
||||
get: getResponsesEndpoint,
|
||||
post: createResponseEndpoint,
|
||||
},
|
||||
"/responses/{id}": {
|
||||
get: getResponseEndpoint,
|
||||
put: updateResponseEndpoint,
|
||||
delete: deleteResponseEndpoint,
|
||||
},
|
||||
};
|
||||
@@ -41,3 +41,36 @@ export const fetchEnvironmentId = reactCache(async (id: string, isResponseId: bo
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const fetchEnvironmentIdFromSurveyIds = reactCache(async (surveyIds: string[]) =>
|
||||
cache(
|
||||
async (): Promise<Result<string[], ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const results = await prisma.survey.findMany({
|
||||
where: { id: { in: surveyIds } },
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (results.length !== surveyIds.length) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
|
||||
return ok(results.map((result) => result.environmentId));
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "survey", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`services-fetchEnvironmentIdFromSurveyIds-${surveyIds.join("-")}`],
|
||||
{
|
||||
tags: surveyIds.map((surveyId) => surveyCache.tag.byId(surveyId)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { fetchEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/services";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { getEnvironmentId } from "../helper";
|
||||
import { getEnvironmentId, getEnvironmentIdFromSurveyIds } from "../helper";
|
||||
import { fetchEnvironmentId } from "../services";
|
||||
|
||||
vi.mock("../services", () => ({
|
||||
fetchEnvironmentId: vi.fn(),
|
||||
fetchEnvironmentIdFromSurveyIds: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Helper Functions", () => {
|
||||
describe("Tests for getEnvironmentId", () => {
|
||||
it("should return environmentId for surveyId", async () => {
|
||||
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
|
||||
|
||||
@@ -41,3 +44,42 @@ describe("Helper Functions", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnvironmentIdFromSurveyIds", () => {
|
||||
const envId1 = createId();
|
||||
const envId2 = createId();
|
||||
|
||||
it("returns the common environment id when all survey ids are in the same environment", async () => {
|
||||
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: [envId1, envId1],
|
||||
});
|
||||
const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||
expect(result).toEqual(ok(envId1));
|
||||
});
|
||||
|
||||
it("returns error when surveys are not in the same environment", async () => {
|
||||
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: [envId1, envId2],
|
||||
});
|
||||
const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "bad_request",
|
||||
details: [{ field: "surveyIds", issue: "not all surveys are in the same environment" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns error when API call fails", async () => {
|
||||
const apiError = {
|
||||
type: "server_error",
|
||||
details: [{ field: "api", issue: "failed" }],
|
||||
} as unknown as ApiErrorResponseV2;
|
||||
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({ ok: false, error: apiError });
|
||||
const result = await getEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||
expect(result).toEqual({ ok: false, error: apiError });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { fetchEnvironmentId } from "../services";
|
||||
import { fetchEnvironmentId, fetchEnvironmentIdFromSurveyIds } from "../services";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: { findFirst: vi.fn() },
|
||||
survey: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Services", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getSurveyAndEnvironmentId", () => {
|
||||
test("should return surveyId and environmentId for responseId", async () => {
|
||||
vi.mocked(prisma.survey.findFirst).mockResolvedValue({
|
||||
@@ -80,4 +79,36 @@ describe("Services", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchEnvironmentIdFromSurveyIds", () => {
|
||||
test("should return an array of environmentIds if all surveys exist", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([
|
||||
{ environmentId: "env-1" },
|
||||
{ environmentId: "env-2" },
|
||||
]);
|
||||
const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(["env-1", "env-2"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("should return not_found error if any survey is missing", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockResolvedValue([{ environmentId: "env-1" }]);
|
||||
const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("not_found");
|
||||
}
|
||||
});
|
||||
|
||||
test("should return internal_server_error if prisma query fails", async () => {
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValue(new Error("Query failed"));
|
||||
const result = await fetchEnvironmentIdFromSurveyIds(["survey1", "survey2"]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { hashApiKey } from "../utils";
|
||||
import { buildCommonFilterQuery, hashApiKey, pickCommonFilter } from "../utils";
|
||||
|
||||
describe("hashApiKey", () => {
|
||||
test("generate the correct sha256 hash for a given input", () => {
|
||||
@@ -15,3 +17,72 @@ describe("hashApiKey", () => {
|
||||
expect(result).toHaveLength(9); // mocked on the vitestSetup.ts file;;
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCommonFilter", () => {
|
||||
test("picks the common filter fields correctly", () => {
|
||||
const params = {
|
||||
limit: 10,
|
||||
skip: 5,
|
||||
sortBy: "createdAt",
|
||||
order: "asc",
|
||||
startDate: new Date("2023-01-01"),
|
||||
endDate: new Date("2023-12-31"),
|
||||
} as TGetFilter;
|
||||
const result = pickCommonFilter(params);
|
||||
expect(result).toEqual(params);
|
||||
});
|
||||
|
||||
test("handles missing fields gracefully", () => {
|
||||
const params = { limit: 10 } as TGetFilter;
|
||||
const result = pickCommonFilter(params);
|
||||
expect(result).toEqual({
|
||||
limit: 10,
|
||||
skip: undefined,
|
||||
sortBy: undefined,
|
||||
order: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCommonFilterQuery", () => {
|
||||
test("applies startDate and endDate when provided", () => {
|
||||
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||
const params = {
|
||||
startDate: new Date("2023-01-01"),
|
||||
endDate: new Date("2023-12-31"),
|
||||
} as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
expect(result.where?.createdAt?.gte).toEqual(params.startDate);
|
||||
expect(result.where?.createdAt?.lte).toEqual(params.endDate);
|
||||
});
|
||||
|
||||
test("applies sortBy and order when provided", () => {
|
||||
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||
const params = { sortBy: "createdAt", order: "desc" } as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
expect(result.orderBy).toEqual({ createdAt: "desc" });
|
||||
});
|
||||
|
||||
test("applies limit (take) when provided", () => {
|
||||
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||
const params = { limit: 5 } as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
expect(result.take).toBe(5);
|
||||
});
|
||||
|
||||
test("applies skip when provided", () => {
|
||||
const query: Prisma.WebhookFindManyArgs = { where: {} };
|
||||
const params = { skip: 10 } as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
expect(result.skip).toBe(10);
|
||||
});
|
||||
|
||||
test("handles missing fields gracefully", () => {
|
||||
const query = {};
|
||||
const params = {} as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,65 @@
|
||||
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
|
||||
export function pickCommonFilter<T extends TGetFilter>(params: T) {
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params;
|
||||
return { limit, skip, sortBy, order, startDate, endDate };
|
||||
}
|
||||
|
||||
type HasFindMany = Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs;
|
||||
|
||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
|
||||
|
||||
let filteredQuery = {
|
||||
...query,
|
||||
};
|
||||
|
||||
if (startDate) {
|
||||
filteredQuery = {
|
||||
...filteredQuery,
|
||||
where: {
|
||||
...filteredQuery.where,
|
||||
createdAt: {
|
||||
...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
|
||||
gte: startDate,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
filteredQuery = {
|
||||
...filteredQuery,
|
||||
where: {
|
||||
...filteredQuery.where,
|
||||
createdAt: {
|
||||
...((filteredQuery.where?.createdAt as Prisma.DateTimeFilter) ?? {}),
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (sortBy) {
|
||||
filteredQuery = {
|
||||
...filteredQuery,
|
||||
orderBy: {
|
||||
[sortBy]: order,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (limit) {
|
||||
filteredQuery = { ...filteredQuery, take: limit };
|
||||
}
|
||||
|
||||
if (skip) {
|
||||
filteredQuery = { ...filteredQuery, skip };
|
||||
}
|
||||
|
||||
return filteredQuery;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { displayCache } from "@formbricks/lib/display/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
@@ -26,7 +27,10 @@ export const deleteDisplay = async (displayId: string): Promise<Result<boolean,
|
||||
return ok(true);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2016" || error.code === "P2025") {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "display", issue: "not found" }],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { responseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
@@ -19,7 +20,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Response retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponse,
|
||||
schema: makePartialSchema(ZResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -41,7 +42,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Response deleted successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponse,
|
||||
schema: makePartialSchema(ZResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -72,7 +73,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Response updated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponse,
|
||||
schema: makePartialSchema(ZResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
|
||||
@@ -77,7 +78,10 @@ export const deleteResponse = async (responseId: string): Promise<Result<Respons
|
||||
return ok(deletedResponse);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2016" || error.code === "P2025") {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "response", issue: "not found" }],
|
||||
@@ -116,7 +120,10 @@ export const updateResponse = async (
|
||||
return ok(updatedResponse);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2016" || error.code === "P2025") {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "response", issue: "not found" }],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { displayId, mockDisplay } from "./__mocks__/display.mock";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { deleteDisplay } from "../display";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -39,7 +40,7 @@ describe("Display Lib", () => {
|
||||
test("return a not_found error when the display is not found", async () => {
|
||||
vi.mocked(prisma.display.delete).mockRejectedValue(
|
||||
new PrismaClientKnownRequestError("Display not found", {
|
||||
code: "P2025",
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
meta: {
|
||||
cause: "Display not found",
|
||||
|
||||
+3
-2
@@ -2,6 +2,7 @@ import { response, responseId, responseInput, survey } from "./__mocks__/respons
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ok, okVoid } from "@formbricks/types/error-handlers";
|
||||
import { deleteDisplay } from "../display";
|
||||
import { deleteResponse, getResponse, updateResponse } from "../response";
|
||||
@@ -154,7 +155,7 @@ describe("Response Lib", () => {
|
||||
test("handle prisma client error code P2025", async () => {
|
||||
vi.mocked(prisma.response.delete).mockRejectedValue(
|
||||
new PrismaClientKnownRequestError("Response not found", {
|
||||
code: "P2025",
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
meta: {
|
||||
cause: "Response not found",
|
||||
@@ -192,7 +193,7 @@ describe("Response Lib", () => {
|
||||
test("return a not_found error when the response is not found", async () => {
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new PrismaClientKnownRequestError("Response not found", {
|
||||
code: "P2025",
|
||||
code: PrismaErrorType.RelatedRecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
meta: {
|
||||
cause: "Response not found",
|
||||
|
||||
@@ -47,7 +47,7 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
|
||||
return handleApiError(request, response.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: response.data });
|
||||
return responses.successResponse(response);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
|
||||
return handleApiError(request, response.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: response.data });
|
||||
return responses.successResponse(response);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -130,6 +130,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
return handleApiError(request, response.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: response.data });
|
||||
return responses.successResponse(response);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,10 +3,11 @@ import {
|
||||
getResponseEndpoint,
|
||||
updateResponseEndpoint,
|
||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
|
||||
import { ZGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
|
||||
export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getResponses",
|
||||
@@ -21,7 +22,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Responses retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(ZResponse),
|
||||
schema: z.array(responseWithMetaSchema(makePartialSchema(ZResponse))),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -47,7 +48,7 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Response created successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponse,
|
||||
schema: makePartialSchema(ZResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -48,7 +48,7 @@ export const getOrganizationIdFromEnvironmentId = reactCache(async (environmentI
|
||||
|
||||
export const getOrganizationBilling = reactCache(async (organizationId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<Pick<Organization, "billing">, ApiErrorResponseV2>> => {
|
||||
async (): Promise<Result<Organization["billing"], ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
@@ -62,7 +62,8 @@ export const getOrganizationBilling = reactCache(async (organizationId: string)
|
||||
if (!organization) {
|
||||
return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] });
|
||||
}
|
||||
return ok(organization);
|
||||
|
||||
return ok(organization.billing);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
@@ -126,26 +127,27 @@ export const getMonthlyOrganizationResponseCount = reactCache(async (organizatio
|
||||
cache(
|
||||
async (): Promise<Result<number, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const organization = await getOrganizationBilling(organizationId);
|
||||
if (!organization.ok) {
|
||||
return err(organization.error);
|
||||
const billing = await getOrganizationBilling(organizationId);
|
||||
if (!billing.ok) {
|
||||
return err(billing.error);
|
||||
}
|
||||
|
||||
// Determine the start date based on the plan type
|
||||
let startDate: Date;
|
||||
if (organization.data.billing.plan === "free") {
|
||||
|
||||
if (billing.data.plan === "free") {
|
||||
// For free plans, use the first day of the current calendar month
|
||||
const now = new Date();
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
} else {
|
||||
// For other plans, use the periodStart from billing
|
||||
if (!organization.data.billing.periodStart) {
|
||||
if (!billing.data.periodStart) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "organization", issue: "billing period start is not set" }],
|
||||
});
|
||||
}
|
||||
startDate = organization.data.billing.periodStart;
|
||||
startDate = billing.data.periodStart;
|
||||
}
|
||||
|
||||
// Get all environment IDs for the organization
|
||||
|
||||
@@ -41,7 +41,14 @@ export const createResponse = async (
|
||||
} = responseInput;
|
||||
|
||||
try {
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
let ttc = {};
|
||||
if (initialTtc) {
|
||||
if (finished) {
|
||||
ttc = calculateTtcTotal(initialTtc);
|
||||
} else {
|
||||
ttc = initialTtc;
|
||||
}
|
||||
}
|
||||
|
||||
const prismaData: Prisma.ResponseCreateInput = {
|
||||
survey: {
|
||||
@@ -67,11 +74,11 @@ export const createResponse = async (
|
||||
return err(organizationIdResult.error);
|
||||
}
|
||||
|
||||
const organizationResult = await getOrganizationBilling(organizationIdResult.data);
|
||||
if (!organizationResult.ok) {
|
||||
return err(organizationResult.error);
|
||||
const billing = await getOrganizationBilling(organizationIdResult.data);
|
||||
if (!billing.ok) {
|
||||
return err(billing.error);
|
||||
}
|
||||
const organization = organizationResult.data;
|
||||
const billingData = billing.data;
|
||||
|
||||
const response = await prisma.response.create({
|
||||
data: prismaData,
|
||||
@@ -95,12 +102,12 @@ export const createResponse = async (
|
||||
}
|
||||
|
||||
const responsesCount = responsesCountResult.data;
|
||||
const responsesLimit = organization.billing.limits.monthly.responses;
|
||||
const responsesLimit = billingData.limits?.monthly.responses;
|
||||
|
||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
plan: billingData.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("Organization Lib", () => {
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.billing).toEqual(organizationBilling);
|
||||
expect(result.data).toEqual(organizationBilling);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("Response Lib", () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||
|
||||
const result = await createResponse(environmentId, responseInput);
|
||||
@@ -70,7 +70,7 @@ describe("Response Lib", () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||
|
||||
const result = await createResponse(environmentId, responseInputNotFinished);
|
||||
@@ -85,7 +85,7 @@ describe("Response Lib", () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||
|
||||
const result = await createResponse(environmentId, responseInputWithoutTtc);
|
||||
@@ -100,7 +100,7 @@ describe("Response Lib", () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||
|
||||
const result = await createResponse(environmentId, responseInputWithoutDisplay);
|
||||
@@ -145,7 +145,7 @@ describe("Response Lib", () => {
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
||||
|
||||
@@ -165,7 +165,7 @@ describe("Response Lib", () => {
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(
|
||||
err({ type: "internal_server_error", details: [{ field: "organization", issue: "Aggregate error" }] })
|
||||
@@ -186,7 +186,7 @@ describe("Response Lib", () => {
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok(organizationBilling));
|
||||
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
||||
|
||||
|
||||
@@ -1,97 +1,40 @@
|
||||
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
|
||||
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getResponsesQuery } from "../utils";
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
pickCommonFilter: vi.fn(),
|
||||
buildCommonFilterQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getResponsesQuery", () => {
|
||||
const environmentId = "env_1";
|
||||
const filters: TGetResponsesFilter = {
|
||||
limit: 10,
|
||||
skip: 0,
|
||||
sortBy: "createdAt",
|
||||
order: "asc",
|
||||
};
|
||||
|
||||
test("return the base query when no params are provided", () => {
|
||||
const query = getResponsesQuery(environmentId);
|
||||
expect(query).toEqual({
|
||||
where: {
|
||||
survey: { environmentId },
|
||||
},
|
||||
});
|
||||
it("adds surveyId to where clause if provided", () => {
|
||||
const result = getResponsesQuery("env-id", { surveyId: "survey123" } as TGetResponsesFilter);
|
||||
expect(result?.where?.surveyId).toBe("survey123");
|
||||
});
|
||||
|
||||
test("add surveyId to the query when provided", () => {
|
||||
const query = getResponsesQuery(environmentId, { ...filters, surveyId: "survey_1" });
|
||||
expect(query.where).toEqual({
|
||||
survey: { environmentId },
|
||||
surveyId: "survey_1",
|
||||
});
|
||||
it("adds contactId to where clause if provided", () => {
|
||||
const result = getResponsesQuery("env-id", { contactId: "contact123" } as TGetResponsesFilter);
|
||||
expect(result?.where?.contactId).toBe("contact123");
|
||||
});
|
||||
|
||||
test("add startDate filter to the query", () => {
|
||||
const startDate = new Date("2023-01-01");
|
||||
const query = getResponsesQuery(environmentId, { ...filters, startDate });
|
||||
expect(query.where).toEqual({
|
||||
survey: { environmentId },
|
||||
createdAt: { gte: startDate },
|
||||
});
|
||||
});
|
||||
it("calls pickCommonFilter & buildCommonFilterQuery with correct arguments", () => {
|
||||
vi.mocked(pickCommonFilter).mockReturnValueOnce({ someFilter: true } as any);
|
||||
vi.mocked(buildCommonFilterQuery).mockReturnValueOnce({ where: { combined: true } as any });
|
||||
|
||||
test("add endDate filter to the query", () => {
|
||||
const endDate = new Date("2023-01-31");
|
||||
const query = getResponsesQuery(environmentId, { ...filters, endDate });
|
||||
expect(query.where).toEqual({
|
||||
survey: { environmentId },
|
||||
createdAt: { lte: endDate },
|
||||
});
|
||||
});
|
||||
|
||||
test("add sortBy and order to the query", () => {
|
||||
const query = getResponsesQuery(environmentId, { ...filters, sortBy: "createdAt", order: "desc" });
|
||||
expect(query.orderBy).toEqual({
|
||||
createdAt: "desc",
|
||||
});
|
||||
});
|
||||
|
||||
test("add limit (take) to the query", () => {
|
||||
const query = getResponsesQuery(environmentId, { ...filters, limit: 10 });
|
||||
expect(query.take).toBe(10);
|
||||
});
|
||||
|
||||
test("add skip to the query", () => {
|
||||
const query = getResponsesQuery(environmentId, { ...filters, skip: 5 });
|
||||
expect(query.skip).toBe(5);
|
||||
});
|
||||
|
||||
test("add contactId to the query", () => {
|
||||
const query = getResponsesQuery(environmentId, { ...filters, contactId: "contact_1" });
|
||||
expect(query.where).toEqual({
|
||||
survey: { environmentId },
|
||||
contactId: "contact_1",
|
||||
});
|
||||
});
|
||||
|
||||
test("combine multiple filters correctly", () => {
|
||||
const params = {
|
||||
...filters,
|
||||
surveyId: "survey_1",
|
||||
startDate: new Date("2023-01-01"),
|
||||
endDate: new Date("2023-01-31"),
|
||||
limit: 20,
|
||||
skip: 10,
|
||||
contactId: "contact_1",
|
||||
};
|
||||
const query = getResponsesQuery(environmentId, params);
|
||||
expect(query.where).toEqual({
|
||||
survey: { environmentId },
|
||||
surveyId: "survey_1",
|
||||
createdAt: { lte: params.endDate, gte: params.startDate },
|
||||
contactId: "contact_1",
|
||||
});
|
||||
expect(query.orderBy).toEqual({
|
||||
createdAt: "asc",
|
||||
});
|
||||
expect(query.take).toBe(20);
|
||||
expect(query.skip).toBe(10);
|
||||
const result = getResponsesQuery("env-id", { surveyId: "test" } as TGetResponsesFilter);
|
||||
expect(pickCommonFilter).toHaveBeenCalledWith({ surveyId: "test" });
|
||||
expect(buildCommonFilterQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining<Prisma.ResponseFindManyArgs>({
|
||||
where: {
|
||||
survey: { environmentId: "env-id" },
|
||||
surveyId: "test",
|
||||
},
|
||||
}),
|
||||
{ someFilter: true }
|
||||
);
|
||||
expect(result).toEqual({ where: { combined: true } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
|
||||
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export const getResponsesQuery = (environmentId: string, params?: TGetResponsesFilter) => {
|
||||
const { surveyId, limit, skip, sortBy, order, startDate, endDate, contactId } = params || {};
|
||||
|
||||
let query: Prisma.ResponseFindManyArgs = {
|
||||
where: {
|
||||
survey: {
|
||||
@@ -12,6 +11,10 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
|
||||
},
|
||||
};
|
||||
|
||||
if (!params) return query;
|
||||
|
||||
const { surveyId, contactId } = params || {};
|
||||
|
||||
if (surveyId) {
|
||||
query = {
|
||||
...query,
|
||||
@@ -22,55 +25,6 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
|
||||
};
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
query = {
|
||||
...query,
|
||||
where: {
|
||||
...query.where,
|
||||
createdAt: {
|
||||
...(query.where?.createdAt as Prisma.DateTimeFilter<"Response">),
|
||||
gte: startDate,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
query = {
|
||||
...query,
|
||||
where: {
|
||||
...query.where,
|
||||
createdAt: {
|
||||
...(query.where?.createdAt as Prisma.DateTimeFilter<"Response">),
|
||||
lte: endDate,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (sortBy) {
|
||||
query = {
|
||||
...query,
|
||||
orderBy: {
|
||||
[sortBy]: order,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (limit) {
|
||||
query = {
|
||||
...query,
|
||||
take: limit,
|
||||
};
|
||||
}
|
||||
|
||||
if (skip) {
|
||||
query = {
|
||||
...query,
|
||||
skip: skip,
|
||||
};
|
||||
}
|
||||
|
||||
if (contactId) {
|
||||
query = {
|
||||
...query,
|
||||
@@ -81,5 +35,11 @@ export const getResponsesQuery = (environmentId: string, params?: TGetResponsesF
|
||||
};
|
||||
}
|
||||
|
||||
const baseFilter = pickCommonFilter(params);
|
||||
|
||||
if (baseFilter) {
|
||||
query = buildCommonFilterQuery<Prisma.ResponseFindManyArgs>(query, baseFilter);
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { z } from "zod";
|
||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
|
||||
export const ZGetResponsesFilter = z
|
||||
.object({
|
||||
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
|
||||
skip: z.coerce.number().nonnegative().optional().default(0),
|
||||
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
|
||||
order: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
surveyId: z.string().cuid2().optional(),
|
||||
contactId: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "startDate must be before endDate",
|
||||
export const ZGetResponsesFilter = ZGetFilter.extend({
|
||||
surveyId: z.string().cuid2().optional(),
|
||||
contactId: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
||||
return false;
|
||||
}
|
||||
);
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "startDate must be before endDate",
|
||||
}
|
||||
);
|
||||
|
||||
export type TGetResponsesFilter = z.infer<typeof ZGetResponsesFilter>;
|
||||
|
||||
@@ -39,21 +32,16 @@ export const ZResponseInput = ZResponse.pick({
|
||||
variables: true,
|
||||
ttc: true,
|
||||
meta: true,
|
||||
})
|
||||
.partial({
|
||||
displayId: true,
|
||||
singleUseId: true,
|
||||
endingId: true,
|
||||
language: true,
|
||||
variables: true,
|
||||
ttc: true,
|
||||
meta: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
})
|
||||
.openapi({
|
||||
ref: "responseCreate",
|
||||
description: "A response to create",
|
||||
});
|
||||
}).partial({
|
||||
displayId: true,
|
||||
singleUseId: true,
|
||||
endingId: true,
|
||||
language: true,
|
||||
variables: true,
|
||||
ttc: true,
|
||||
meta: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
export type TResponseInput = z.infer<typeof ZResponseInput>;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { webhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||
|
||||
export const getWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getWebhook",
|
||||
summary: "Get a webhook",
|
||||
description: "Gets a webhook from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Management API > Webhooks"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Webhook retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZWebhook),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteWebhook",
|
||||
summary: "Delete a webhook",
|
||||
description: "Deletes a webhook from the database.",
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Webhook deleted successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZWebhook),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateWebhook",
|
||||
summary: "Update a webhook",
|
||||
description: "Updates a webhook in the database.",
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The webhook to update",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZWebhookInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Webhook updated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZWebhook),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { WebhookSource } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
export const mockedPrismaWebhookUpdateReturn = {
|
||||
id: "123",
|
||||
url: "",
|
||||
name: null,
|
||||
createdAt: new Date("2025-03-24T07:27:36.850Z"),
|
||||
updatedAt: new Date("2025-03-24T07:27:36.850Z"),
|
||||
source: "user" as WebhookSource,
|
||||
environmentId: "",
|
||||
triggers: [],
|
||||
surveyIds: [],
|
||||
};
|
||||
|
||||
export const prismaNotFoundError = new PrismaClientKnownRequestError("Record does not exist", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "PrismaClient 4.0.0",
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import {
|
||||
mockedPrismaWebhookUpdateReturn,
|
||||
prismaNotFoundError,
|
||||
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock";
|
||||
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { deleteWebhook, getWebhook, updateWebhook } from "../webhook";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
webhook: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/webhook", () => ({
|
||||
webhookCache: {
|
||||
tag: {
|
||||
byId: () => "mockTag",
|
||||
},
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getWebhook", () => {
|
||||
test("returns ok if webhook is found", async () => {
|
||||
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce({ id: "123" });
|
||||
const result = await getWebhook("123");
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({ id: "123" });
|
||||
}
|
||||
});
|
||||
|
||||
test("returns err if webhook not found", async () => {
|
||||
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(null);
|
||||
const result = await getWebhook("999");
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error?.type).toBe("not_found");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns err on Prisma error", async () => {
|
||||
vi.mocked(prisma.webhook.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||
const result = await getWebhook("error");
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWebhook", () => {
|
||||
const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer<typeof webhookUpdateSchema>;
|
||||
|
||||
test("returns ok on successful update", async () => {
|
||||
vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
|
||||
const result = await updateWebhook("123", mockedWebhookUpdateReturn);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(mockedPrismaWebhookUpdateReturn);
|
||||
}
|
||||
|
||||
expect(webhookCache.revalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns not_found if record does not exist", async () => {
|
||||
vi.mocked(prisma.webhook.update).mockRejectedValueOnce(prismaNotFoundError);
|
||||
const result = await updateWebhook("999", mockedWebhookUpdateReturn);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error?.type).toBe("not_found");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns internal_server_error if other error occurs", async () => {
|
||||
vi.mocked(prisma.webhook.update).mockRejectedValueOnce(new Error("Unknown error"));
|
||||
const result = await updateWebhook("abc", mockedWebhookUpdateReturn);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error?.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWebhook", () => {
|
||||
test("returns ok on successful delete", async () => {
|
||||
vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
|
||||
const result = await deleteWebhook("123");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(webhookCache.revalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns not_found if record does not exist", async () => {
|
||||
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaNotFoundError);
|
||||
const result = await deleteWebhook("999");
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error?.type).toBe("not_found");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns internal_server_error on other errors", async () => {
|
||||
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(new Error("Delete error"));
|
||||
const result = await deleteWebhook("abc");
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error?.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getWebhook = async (webhookId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const webhook = await prisma.webhook.findUnique({
|
||||
where: {
|
||||
id: webhookId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "webhook", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
|
||||
return ok(webhook);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "webhook", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`management-getWebhook-${webhookId}`],
|
||||
{
|
||||
tags: [webhookCache.tag.byId(webhookId)],
|
||||
}
|
||||
)();
|
||||
|
||||
export const updateWebhook = async (
|
||||
webhookId: string,
|
||||
webhookInput: z.infer<typeof webhookUpdateSchema>
|
||||
): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const updatedWebhook = await prisma.webhook.update({
|
||||
where: {
|
||||
id: webhookId,
|
||||
},
|
||||
data: webhookInput,
|
||||
});
|
||||
|
||||
webhookCache.revalidate({
|
||||
id: webhookId,
|
||||
});
|
||||
|
||||
return ok(updatedWebhook);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "webhook", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "webhook", issue: error.message }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteWebhook = async (webhookId: string): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const deletedWebhook = await prisma.webhook.delete({
|
||||
where: {
|
||||
id: webhookId,
|
||||
},
|
||||
});
|
||||
|
||||
webhookCache.revalidate({
|
||||
id: deletedWebhook.id,
|
||||
environmentId: deletedWebhook.environmentId,
|
||||
source: deletedWebhook.source,
|
||||
});
|
||||
|
||||
return ok(deletedWebhook);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "webhook", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "webhook", issue: error.message }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
|
||||
import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
|
||||
import {
|
||||
deleteWebhook,
|
||||
getWebhook,
|
||||
updateWebhook,
|
||||
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/webhook";
|
||||
import {
|
||||
webhookIdSchema,
|
||||
webhookUpdateSchema,
|
||||
} from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
export const GET = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ webhookId: webhookIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { params } = parsedInput;
|
||||
|
||||
if (!params) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "params", issue: "missing" }],
|
||||
});
|
||||
}
|
||||
|
||||
const webhook = await getWebhook(params.webhookId);
|
||||
|
||||
if (!webhook.ok) {
|
||||
return handleApiError(request, webhook.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: webhook.ok ? webhook.data.environmentId : "",
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
}
|
||||
|
||||
return responses.successResponse(webhook);
|
||||
},
|
||||
});
|
||||
|
||||
export const PUT = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ webhookId: webhookIdSchema }),
|
||||
body: webhookUpdateSchema,
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { params, body } = parsedInput;
|
||||
|
||||
if (!body || !params) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: !body ? "body" : "params", issue: "missing" }],
|
||||
});
|
||||
}
|
||||
|
||||
// get surveys environment
|
||||
const surveysEnvironmentId = await getEnvironmentIdFromSurveyIds(body.surveyIds);
|
||||
|
||||
if (!surveysEnvironmentId.ok) {
|
||||
return handleApiError(request, surveysEnvironmentId.error);
|
||||
}
|
||||
|
||||
// get webhook environment
|
||||
const webhook = await getWebhook(params.webhookId);
|
||||
|
||||
if (!webhook.ok) {
|
||||
return handleApiError(request, webhook.error);
|
||||
}
|
||||
|
||||
// check webhook environment against the api key environment
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: webhook.ok ? webhook.data.environmentId : "",
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
}
|
||||
|
||||
// check if webhook environment matches the surveys environment
|
||||
if (webhook.data.environmentId !== surveysEnvironmentId.data) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [
|
||||
{ field: "surveys id", issue: "webhook environment does not match the surveys environment" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const updatedWebhook = await updateWebhook(params.webhookId, body);
|
||||
|
||||
if (!updatedWebhook.ok) {
|
||||
return handleApiError(request, updatedWebhook.error);
|
||||
}
|
||||
|
||||
return responses.successResponse(updatedWebhook);
|
||||
},
|
||||
});
|
||||
|
||||
export const DELETE = async (request: NextRequest, props: { params: Promise<{ webhookId: string }> }) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ webhookId: webhookIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { params } = parsedInput;
|
||||
|
||||
if (!params) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "params", issue: "missing" }],
|
||||
});
|
||||
}
|
||||
|
||||
const webhook = await getWebhook(params.webhookId);
|
||||
|
||||
if (!webhook.ok) {
|
||||
return handleApiError(request, webhook.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: webhook.ok ? webhook.data.environmentId : "",
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
}
|
||||
|
||||
const deletedWebhook = await deleteWebhook(params.webhookId);
|
||||
|
||||
if (!deletedWebhook.ok) {
|
||||
return handleApiError(request, deletedWebhook.error);
|
||||
}
|
||||
|
||||
return responses.successResponse(deletedWebhook);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const webhookIdSchema = z
|
||||
.string()
|
||||
.cuid2()
|
||||
.openapi({
|
||||
ref: "webhookId",
|
||||
description: "The ID of the webhook",
|
||||
param: {
|
||||
name: "id",
|
||||
in: "path",
|
||||
},
|
||||
});
|
||||
|
||||
export const webhookUpdateSchema = ZWebhook.omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
}).openapi({
|
||||
ref: "webhookUpdate",
|
||||
description: "A webhook to update.",
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
deleteWebhookEndpoint,
|
||||
getWebhookEndpoint,
|
||||
updateWebhookEndpoint,
|
||||
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/openapi";
|
||||
import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||
|
||||
export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getWebhooks",
|
||||
summary: "Get webhooks",
|
||||
description: "Gets webhooks from the database.",
|
||||
requestParams: {
|
||||
query: ZGetWebhooksFilter.sourceType().required(),
|
||||
},
|
||||
tags: ["Management API > Webhooks"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Webhooks retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(responseWithMetaSchema(makePartialSchema(ZWebhook))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const createWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createWebhook",
|
||||
summary: "Create a webhook",
|
||||
description: "Creates a webhook in the database.",
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The webhook to create",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZWebhookInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Webhook created successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZWebhook),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const webhookPaths: ZodOpenApiPathsObject = {
|
||||
"/webhooks": {
|
||||
get: getWebhooksEndpoint,
|
||||
post: createWebhookEndpoint,
|
||||
},
|
||||
"/webhooks/{webhookId}": {
|
||||
get: getWebhookEndpoint,
|
||||
put: updateWebhookEndpoint,
|
||||
delete: deleteWebhookEndpoint,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
|
||||
import { TGetWebhooksFilter } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getWebhooksQuery } from "../utils";
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
pickCommonFilter: vi.fn(),
|
||||
buildCommonFilterQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getWebhooksQuery", () => {
|
||||
const environmentId = "env-123";
|
||||
|
||||
it("adds surveyIds condition when provided", () => {
|
||||
const params = { surveyIds: ["survey1"] } as TGetWebhooksFilter;
|
||||
const result = getWebhooksQuery(environmentId, params);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.where).toMatchObject({
|
||||
environmentId,
|
||||
surveyIds: { hasSome: ["survey1"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("calls pickCommonFilter and buildCommonFilterQuery when baseFilter is present", () => {
|
||||
vi.mocked(pickCommonFilter).mockReturnValue({ someFilter: "test" } as any);
|
||||
getWebhooksQuery(environmentId, { surveyIds: ["survey1"] } as TGetWebhooksFilter);
|
||||
expect(pickCommonFilter).toHaveBeenCalled();
|
||||
expect(buildCommonFilterQuery).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("buildCommonFilterQuery is not called if no baseFilter is picked", () => {
|
||||
vi.mocked(pickCommonFilter).mockReturnValue(undefined as any);
|
||||
getWebhooksQuery(environmentId, {} as any);
|
||||
expect(buildCommonFilterQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { WebhookSource } from "@prisma/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { createWebhook, getWebhooks } from "../webhook";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(),
|
||||
webhook: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/cache/webhook", () => ({
|
||||
webhookCache: {
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/lib/telemetry", () => ({
|
||||
captureTelemetry: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getWebhooks", () => {
|
||||
const environmentId = "env1";
|
||||
const params = {
|
||||
limit: 10,
|
||||
skip: 0,
|
||||
};
|
||||
const fakeWebhooks = [
|
||||
{ id: "w1", environmentId, name: "Webhook One" },
|
||||
{ id: "w2", environmentId, name: "Webhook Two" },
|
||||
];
|
||||
const count = fakeWebhooks.length;
|
||||
|
||||
it("returns ok response with webhooks and meta", async () => {
|
||||
vi.mocked(prisma.$transaction).mockResolvedValueOnce([fakeWebhooks, count]);
|
||||
|
||||
const result = await getWebhooks(environmentId, params as TGetWebhooksFilter);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.data.data).toEqual(fakeWebhooks);
|
||||
expect(result.data.meta).toEqual({
|
||||
total: count,
|
||||
limit: params.limit,
|
||||
offset: params.skip,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns error when prisma.$transaction throws", async () => {
|
||||
vi.mocked(prisma.$transaction).mockRejectedValueOnce(new Error("Test error"));
|
||||
|
||||
const result = await getWebhooks(environmentId, params as TGetWebhooksFilter);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error?.type).toEqual("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWebhook", () => {
|
||||
const inputWebhook = {
|
||||
environmentId: "env1",
|
||||
name: "New Webhook",
|
||||
url: "http://example.com",
|
||||
source: "user" as WebhookSource,
|
||||
triggers: ["trigger1"],
|
||||
surveyIds: ["s1", "s2"],
|
||||
} as unknown as TWebhookInput;
|
||||
|
||||
const createdWebhook = {
|
||||
id: "w100",
|
||||
environmentId: inputWebhook.environmentId,
|
||||
name: inputWebhook.name,
|
||||
url: inputWebhook.url,
|
||||
source: inputWebhook.source,
|
||||
triggers: inputWebhook.triggers,
|
||||
surveyIds: inputWebhook.surveyIds,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
it("creates a webhook and revalidates cache", async () => {
|
||||
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
|
||||
|
||||
const result = await createWebhook(inputWebhook);
|
||||
expect(captureTelemetry).toHaveBeenCalledWith("webhook_created");
|
||||
expect(prisma.webhook.create).toHaveBeenCalled();
|
||||
expect(webhookCache.revalidate).toHaveBeenCalledWith({
|
||||
environmentId: createdWebhook.environmentId,
|
||||
source: createdWebhook.source,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(createdWebhook);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns error when creation fails", async () => {
|
||||
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Creation failed"));
|
||||
|
||||
const result = await createWebhook(inputWebhook);
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toEqual("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
|
||||
import { TGetWebhooksFilter } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export const getWebhooksQuery = (environmentId: string, params?: TGetWebhooksFilter) => {
|
||||
let query: Prisma.WebhookFindManyArgs = {
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
};
|
||||
|
||||
if (!params) return query;
|
||||
|
||||
const { surveyIds } = params || {};
|
||||
|
||||
if (surveyIds) {
|
||||
query = {
|
||||
...query,
|
||||
where: {
|
||||
...query.where,
|
||||
surveyIds: {
|
||||
hasSome: surveyIds,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseFilter = pickCommonFilter(params);
|
||||
|
||||
if (baseFilter) {
|
||||
query = buildCommonFilterQuery<Prisma.WebhookFindManyArgs>(query, baseFilter);
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
|
||||
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getWebhooks = async (
|
||||
environmentId: string,
|
||||
params: TGetWebhooksFilter
|
||||
): Promise<Result<ApiResponseWithMeta<Webhook[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const [webhooks, count] = await prisma.$transaction([
|
||||
prisma.webhook.findMany({
|
||||
...getWebhooksQuery(environmentId, params),
|
||||
}),
|
||||
prisma.webhook.count({
|
||||
where: getWebhooksQuery(environmentId, params).where,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!webhooks) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "webhooks", issue: "not_found" }],
|
||||
});
|
||||
}
|
||||
|
||||
return ok({
|
||||
data: webhooks,
|
||||
meta: {
|
||||
total: count,
|
||||
limit: params?.limit,
|
||||
offset: params?.skip,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "webhooks", issue: error.message }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||
captureTelemetry("webhook_created");
|
||||
|
||||
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
|
||||
|
||||
try {
|
||||
const prismaData: Prisma.WebhookCreateInput = {
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
name,
|
||||
url,
|
||||
source,
|
||||
triggers,
|
||||
surveyIds,
|
||||
};
|
||||
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
data: prismaData,
|
||||
});
|
||||
|
||||
webhookCache.revalidate({
|
||||
environmentId: createdWebhook.environmentId,
|
||||
source: createdWebhook.source,
|
||||
});
|
||||
|
||||
return ok(createdWebhook);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "webhook", issue: error.message }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
|
||||
import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
|
||||
import { createWebhook, getWebhooks } from "@/modules/api/v2/management/webhooks/lib/webhook";
|
||||
import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = async (request: NextRequest) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
query: ZGetWebhooksFilter.sourceType(),
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { query } = parsedInput;
|
||||
|
||||
if (!query) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "query", issue: "missing" }],
|
||||
});
|
||||
}
|
||||
|
||||
const environmentId = authentication.environmentId;
|
||||
|
||||
const res = await getWebhooks(environmentId, query);
|
||||
|
||||
if (res.ok) {
|
||||
return responses.successResponse(res.data);
|
||||
}
|
||||
|
||||
return handleApiError(request, res.error);
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (request: NextRequest) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
body: ZWebhookInput,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { body } = parsedInput;
|
||||
|
||||
if (!body) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "body", issue: "missing" }],
|
||||
});
|
||||
}
|
||||
|
||||
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
|
||||
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const environmentId = environmentIdResult.data;
|
||||
|
||||
if (body.environmentId !== environmentId) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "environmentId", issue: "does not match the surveys environment" }],
|
||||
});
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
}
|
||||
|
||||
const createWebhookResult = await createWebhook(body);
|
||||
|
||||
if (!createWebhookResult.ok) {
|
||||
return handleApiError(request, createWebhookResult.error);
|
||||
}
|
||||
|
||||
return responses.successResponse(createWebhookResult);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { z } from "zod";
|
||||
import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||
|
||||
export const ZGetWebhooksFilter = ZGetFilter.extend({
|
||||
surveyIds: z.array(z.string().cuid2()).optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "startDate must be before endDate",
|
||||
}
|
||||
);
|
||||
|
||||
export type TGetWebhooksFilter = z.infer<typeof ZGetWebhooksFilter>;
|
||||
|
||||
export const ZWebhookInput = ZWebhook.pick({
|
||||
name: true,
|
||||
url: true,
|
||||
source: true,
|
||||
environmentId: true,
|
||||
triggers: true,
|
||||
surveyIds: true,
|
||||
});
|
||||
|
||||
export type TWebhookInput = z.infer<typeof ZWebhookInput>;
|
||||
@@ -3,6 +3,7 @@ import { contactAttributePaths } from "@/modules/api/v2/management/contact-attri
|
||||
import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
|
||||
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
|
||||
import { surveyPaths } from "@/modules/api/v2/management/surveys/lib/openapi";
|
||||
import { webhookPaths } from "@/modules/api/v2/management/webhooks/lib/openapi";
|
||||
import * as yaml from "yaml";
|
||||
import { z } from "zod";
|
||||
import { createDocument, extendZodWithOpenApi } from "zod-openapi";
|
||||
@@ -11,6 +12,7 @@ import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute
|
||||
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
|
||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
|
||||
import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
@@ -27,6 +29,7 @@ const document = createDocument({
|
||||
...contactAttributePaths,
|
||||
...contactAttributeKeyPaths,
|
||||
...surveyPaths,
|
||||
...webhookPaths,
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
@@ -55,6 +58,10 @@ const document = createDocument({
|
||||
name: "Management API > Surveys",
|
||||
description: "Operations for managing surveys.",
|
||||
},
|
||||
{
|
||||
name: "Management API > Webhooks",
|
||||
description: "Operations for managing webhooks.",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
@@ -71,6 +78,7 @@ const document = createDocument({
|
||||
contactAttribute: ZContactAttribute,
|
||||
contactAttributeKey: ZContactAttributeKey,
|
||||
survey: ZSurveyWithoutQuestionType,
|
||||
webhook: ZWebhook,
|
||||
},
|
||||
},
|
||||
security: [
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZGetFilter = z.object({
|
||||
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
|
||||
skip: z.coerce.number().nonnegative().optional().default(0),
|
||||
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
|
||||
order: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export type TGetFilter = z.infer<typeof ZGetFilter>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export function responseWithMetaSchema<T extends z.ZodTypeAny>(contentSchema: T) {
|
||||
return z.object({
|
||||
data: z.array(contentSchema).optional(),
|
||||
meta: z
|
||||
.object({
|
||||
total: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
}
|
||||
|
||||
// We use the partial method to make all properties optional so we don't show the response fields as required in the OpenAPI documentation
|
||||
export function makePartialSchema<T extends z.ZodObject<any>>(schema: T) {
|
||||
return schema.partial();
|
||||
}
|
||||
Reference in New Issue
Block a user