mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-31 00:50:34 -06:00
feat: Added Webhooks in Management API V2 (#4949)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -25,7 +26,10 @@ export const deleteWebhook = async (id: string): Promise<Webhook> => {
|
||||
|
||||
return deletedWebhook;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
throw new ResourceNotFoundError("Webhook", id);
|
||||
}
|
||||
throw new DatabaseError(`Database error when deleting webhook with ID ${id}`);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DELETE, GET, PUT } from "@/modules/api/v2/management/webhooks/[webhookId]/route";
|
||||
|
||||
export { GET, PUT, DELETE };
|
||||
3
apps/web/app/api/v2/management/webhooks/route.ts
Normal file
3
apps/web/app/api/v2/management/webhooks/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET, POST } from "@/modules/api/v2/management/webhooks/route";
|
||||
|
||||
export { GET, POST };
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 }],
|
||||
});
|
||||
}
|
||||
};
|
||||
156
apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts
Normal file
156
apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts
Normal file
@@ -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.",
|
||||
});
|
||||
68
apps/web/modules/api/v2/management/webhooks/lib/openapi.ts
Normal file
68
apps/web/modules/api/v2/management/webhooks/lib/openapi.ts
Normal file
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
35
apps/web/modules/api/v2/management/webhooks/lib/utils.ts
Normal file
35
apps/web/modules/api/v2/management/webhooks/lib/utils.ts
Normal file
@@ -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;
|
||||
};
|
||||
83
apps/web/modules/api/v2/management/webhooks/lib/webhook.ts
Normal file
83
apps/web/modules/api/v2/management/webhooks/lib/webhook.ts
Normal file
@@ -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 }],
|
||||
});
|
||||
}
|
||||
};
|
||||
86
apps/web/modules/api/v2/management/webhooks/route.ts
Normal file
86
apps/web/modules/api/v2/management/webhooks/route.ts
Normal file
@@ -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: [
|
||||
|
||||
12
apps/web/modules/api/v2/types/api-filter.ts
Normal file
12
apps/web/modules/api/v2/types/api-filter.ts
Normal file
@@ -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>;
|
||||
19
apps/web/modules/api/v2/types/openapi-response.ts
Normal file
19
apps/web/modules/api/v2/types/openapi-response.ts
Normal file
@@ -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();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { userCache } from "@formbricks/lib/user/cache";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { mockUser } from "./mock-data";
|
||||
@@ -57,7 +58,7 @@ describe("User Management", () => {
|
||||
|
||||
it("throws InvalidInputError when email already exists", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.user.create).mockRejectedValueOnce(errToThrow);
|
||||
@@ -86,7 +87,7 @@ describe("User Management", () => {
|
||||
|
||||
it("throws ResourceNotFoundError when user doesn't exist", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
|
||||
code: "P2016",
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.user.update).mockRejectedValueOnce(errToThrow);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { userCache } from "@formbricks/lib/user/cache";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
@@ -32,7 +33,10 @@ export const updateUser = async (id: string, data: TUserUpdateInput) => {
|
||||
|
||||
return updatedUser;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.RecordDoesNotExist
|
||||
) {
|
||||
throw new ResourceNotFoundError("User", id);
|
||||
}
|
||||
throw error;
|
||||
@@ -129,7 +133,10 @@ export const createUser = async (data: TUserCreateInput) => {
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
throw new InvalidInputError("User with this email already exists");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { inviteCache } from "@/lib/cache/invite";
|
||||
import { type TInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const updateInvite = async (inviteId: string, data: TInviteUpdateInput): Promise<boolean> => {
|
||||
@@ -22,7 +23,10 @@ export const updateInvite = async (inviteId: string, data: TInviteUpdateInput):
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.RecordDoesNotExist
|
||||
) {
|
||||
throw new ResourceNotFoundError("Invite", inviteId);
|
||||
} else {
|
||||
throw error; // Re-throw any other errors
|
||||
|
||||
@@ -3,6 +3,7 @@ import { membershipCache } from "@/lib/cache/membership";
|
||||
import { teamCache } from "@/lib/cache/team";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { organizationCache } from "@formbricks/lib/organization/cache";
|
||||
import { projectCache } from "@formbricks/lib/project/cache";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
@@ -91,7 +92,11 @@ export const updateMembership = async (
|
||||
|
||||
return membership;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
(error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist)
|
||||
) {
|
||||
throw new ResourceNotFoundError("Membership", `userId: ${userId}, organizationId: ${organizationId}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { organizationCache } from "@formbricks/lib/organization/cache";
|
||||
import { projectCache } from "@formbricks/lib/project/cache";
|
||||
@@ -64,7 +65,10 @@ export const updateOrganizationEmailLogoUrl = async (
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.RecordDoesNotExist
|
||||
) {
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
@@ -125,7 +129,10 @@ export const removeOrganizationEmailLogoUrl = async (organizationId: string): Pr
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.RecordDoesNotExist
|
||||
) {
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -62,7 +63,10 @@ export const deleteWebhook = async (id: string): Promise<boolean> => {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
throw new ResourceNotFoundError("Webhook", id);
|
||||
}
|
||||
throw new DatabaseError(`Database error when deleting webhook with ID ${id}`);
|
||||
|
||||
@@ -2,6 +2,7 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { isS3Configured } from "@formbricks/lib/constants";
|
||||
import { environmentCache } from "@formbricks/lib/environment/cache";
|
||||
import { createEnvironment } from "@formbricks/lib/environment/service";
|
||||
@@ -140,12 +141,15 @@ export const createProject = async (
|
||||
|
||||
return updatedProject;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
throw new InvalidInputError("A project with this name already exists in your organization");
|
||||
}
|
||||
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2002") {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("A project with this name already exists in this organization");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { userCache } from "@formbricks/lib/user/cache";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { TUserUpdateInput } from "@formbricks/types/user";
|
||||
import { TUser, TUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
// function to update a user's user
|
||||
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
|
||||
@@ -37,7 +37,10 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
|
||||
|
||||
return updatedUser;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.RecordDoesNotExist
|
||||
) {
|
||||
throw new ResourceNotFoundError("User", personId);
|
||||
}
|
||||
throw error; // Re-throw any other errors
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ActionClass, Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { actionClassCache } from "@formbricks/lib/actionClass/cache";
|
||||
import { TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
@@ -28,7 +29,10 @@ export const createActionClass = async (
|
||||
|
||||
return actionClassPrisma;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
throw new DatabaseError(
|
||||
`Action with ${error.meta?.target?.[0]} ${actionClass[error.meta?.target?.[0]]} already exists`
|
||||
);
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const RESPONSES_API_URL = `/api/v2/management/responses`;
|
||||
export const SURVEYS_API_URL = `/api/v1/management/surveys`;
|
||||
export const WEBHOOKS_API_URL = `/api/v2/management/webhooks`;
|
||||
|
||||
137
apps/web/playwright/api/management/webhook.spec.ts
Normal file
137
apps/web/playwright/api/management/webhook.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { SURVEYS_API_URL, WEBHOOKS_API_URL } from "@/playwright/api/constants";
|
||||
import { expect } from "@playwright/test";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { test } from "../../lib/fixtures";
|
||||
import { loginAndGetApiKey } from "../../lib/utils";
|
||||
|
||||
test.describe("API Tests for Webhooks", () => {
|
||||
test("Create, Retrieve, Update, and Delete Webhooks via API", async ({ page, users, request }) => {
|
||||
let environmentId, apiKey;
|
||||
|
||||
try {
|
||||
({ environmentId, apiKey } = await loginAndGetApiKey(page, users));
|
||||
} catch (error) {
|
||||
logger.error(error, "Error during login and getting API key");
|
||||
throw error;
|
||||
}
|
||||
|
||||
let surveyId: string;
|
||||
|
||||
await test.step("Create Survey via API", async () => {
|
||||
const surveyBody = {
|
||||
environmentId: environmentId,
|
||||
type: "link",
|
||||
name: "My new Survey from API",
|
||||
questions: [
|
||||
{
|
||||
id: "jpvm9b73u06xdrhzi11k2h76",
|
||||
type: "openText",
|
||||
headline: {
|
||||
default: "What would you like to know?",
|
||||
},
|
||||
required: true,
|
||||
inputType: "text",
|
||||
subheader: {
|
||||
default: "This is an example survey.",
|
||||
},
|
||||
placeholder: {
|
||||
default: "Type your answer here...",
|
||||
},
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await request.post(SURVEYS_API_URL, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
data: surveyBody,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseBody = await response.json();
|
||||
expect(responseBody.data.name).toEqual("My new Survey from API");
|
||||
surveyId = responseBody.data.id;
|
||||
});
|
||||
|
||||
let createdWebhookId: string;
|
||||
|
||||
await test.step("Create Webhook via API", async () => {
|
||||
const webhookBody = {
|
||||
environmentId,
|
||||
name: "New Webhook",
|
||||
url: "https://examplewebhook.com",
|
||||
source: "user",
|
||||
triggers: ["responseFinished"],
|
||||
surveyIds: [surveyId],
|
||||
};
|
||||
|
||||
const response = await request.post(WEBHOOKS_API_URL, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
data: webhookBody,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseBody = await response.json();
|
||||
expect(responseBody.data.name).toEqual("New Webhook");
|
||||
createdWebhookId = responseBody.data.id;
|
||||
});
|
||||
|
||||
await test.step("Retrieve Webhooks via API", async () => {
|
||||
const params = { limit: 10, skip: 0, sortBy: "createdAt", order: "desc" };
|
||||
const response = await request.get(WEBHOOKS_API_URL, {
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
params,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseBody = await response.json();
|
||||
const newlyCreated = responseBody.data.find((hook: any) => hook.id === createdWebhookId);
|
||||
expect(newlyCreated).toBeTruthy();
|
||||
expect(newlyCreated.name).toBe("New Webhook");
|
||||
});
|
||||
|
||||
await test.step("Update Webhook by ID via API", async () => {
|
||||
const updatedBody = {
|
||||
environmentId,
|
||||
name: "Updated Webhook",
|
||||
url: "https://updated-webhook-url.com",
|
||||
source: "zapier",
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: [surveyId],
|
||||
};
|
||||
|
||||
const response = await request.put(`${WEBHOOKS_API_URL}/${createdWebhookId}`, {
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: updatedBody,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseJson = await response.json();
|
||||
expect(responseJson.data.name).toBe("Updated Webhook");
|
||||
expect(responseJson.data.source).toBe("zapier");
|
||||
});
|
||||
|
||||
await test.step("Delete Webhook via API", async () => {
|
||||
const deleteResponse = await request.delete(`${WEBHOOKS_API_URL}/${createdWebhookId}`, {
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
expect(deleteResponse.ok()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
4
apps/web/test-results/.last-run.json
Normal file
4
apps/web/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"failedTests": [],
|
||||
"status": "failed"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Webhook" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
|
||||
@@ -48,7 +48,7 @@ model Webhook {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
|
||||
url String
|
||||
source WebhookSource @default(user)
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
|
||||
5
packages/database/types/error.ts
Normal file
5
packages/database/types/error.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum PrismaErrorType {
|
||||
UniqueConstraintViolation = "P2002",
|
||||
RecordDoesNotExist = "P2015",
|
||||
RelatedRecordDoesNotExist = "P2025",
|
||||
}
|
||||
41
packages/database/zod/organizations.ts
Normal file
41
packages/database/zod/organizations.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Organization } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const ZOrganizationWhiteLabel = z.object({
|
||||
logoUrl: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const ZOrganizationBilling = z.object({
|
||||
stripeCustomerId: z.string().nullable(),
|
||||
plan: z.enum(["free", "startup", "scale", "enterprise"]).default("free"),
|
||||
period: z.enum(["monthly", "yearly"]).default("monthly"),
|
||||
limits: z
|
||||
.object({
|
||||
projects: z.number().nullable(),
|
||||
monthly: z.object({
|
||||
responses: z.number().nullable(),
|
||||
miu: z.number().nullable(),
|
||||
}),
|
||||
})
|
||||
.default({
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
}),
|
||||
periodStart: z.coerce.date().nullable(),
|
||||
});
|
||||
|
||||
export const ZOrganization = z.object({
|
||||
id: z.string().cuid2(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
name: z.string(),
|
||||
whitelabel: ZOrganizationWhiteLabel,
|
||||
billing: ZOrganizationBilling as z.ZodType<Organization["billing"]>,
|
||||
isAIEnabled: z.boolean().default(false) as z.ZodType<Organization["isAIEnabled"]>,
|
||||
}) satisfies z.ZodType<Organization>;
|
||||
@@ -1,14 +1,42 @@
|
||||
import type { Webhook } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
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()),
|
||||
id: z.string().cuid2().openapi({
|
||||
description: "The ID of the webhook",
|
||||
}),
|
||||
name: z.string().nullable().openapi({
|
||||
description: "The name of the webhook",
|
||||
}),
|
||||
createdAt: z.date().openapi({
|
||||
description: "The date and time the webhook was created",
|
||||
example: "2021-01-01T00:00:00.000Z",
|
||||
}),
|
||||
updatedAt: z.date().openapi({
|
||||
description: "The date and time the webhook was last updated",
|
||||
example: "2021-01-01T00:00:00.000Z",
|
||||
}),
|
||||
url: z.string().url().openapi({
|
||||
description: "The URL of the webhook",
|
||||
}),
|
||||
source: z.enum(["user", "zapier", "make", "n8n"]).openapi({
|
||||
description: "The source of the webhook",
|
||||
}),
|
||||
environmentId: z.string().cuid2().openapi({
|
||||
description: "The ID of the environment",
|
||||
}),
|
||||
triggers: z.array(z.enum(["responseFinished", "responseCreated", "responseUpdated"])).openapi({
|
||||
description: "The triggers of the webhook",
|
||||
}),
|
||||
surveyIds: z.array(z.string().cuid2()).openapi({
|
||||
description: "The IDs of the surveys ",
|
||||
}),
|
||||
}) satisfies z.ZodType<Webhook>;
|
||||
|
||||
ZWebhook.openapi({
|
||||
ref: "webhook",
|
||||
description: "A webhook",
|
||||
});
|
||||
|
||||
@@ -4,9 +4,9 @@ import "server-only";
|
||||
import { ActionClass, Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { cache } from "../cache";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
@@ -163,7 +163,10 @@ export const createActionClass = async (
|
||||
|
||||
return actionClassPrisma;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
throw new DatabaseError(
|
||||
`Action with ${error.meta?.target?.[0]} ${actionClass[error.meta?.target?.[0]]} already exists`
|
||||
);
|
||||
@@ -219,7 +222,10 @@ export const updateActionClass = async (
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
throw new DatabaseError(
|
||||
`Action with ${error.meta?.target?.[0]} ${inputActionClass[error.meta?.target?.[0]]} already exists`
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { testInputValidation } from "vitestSetup";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { createDisplay } from "../../../../apps/web/app/api/v1/client/[environmentId]/displays/lib/display";
|
||||
import { deleteDisplay } from "../service";
|
||||
@@ -52,7 +53,7 @@ describe("Tests for createDisplay service", () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
prisma.environment.findUnique.mockResolvedValue(mockEnvironment);
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
@@ -83,7 +84,7 @@ describe("Tests for delete display service", () => {
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "./__mocks__/data.mock";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prismaMock } from "@formbricks/database/src/jestClient";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { createLanguage, deleteLanguage, updateLanguage } from "../service";
|
||||
|
||||
@@ -34,7 +35,7 @@ describe("Tests for createLanguage service", () => {
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
@@ -72,7 +73,7 @@ describe("Tests for updateLanguage Service", () => {
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
@@ -109,7 +110,7 @@ describe("Tests for deleteLanguage", () => {
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TOrganization,
|
||||
@@ -224,7 +224,10 @@ export const updateOrganization = async (
|
||||
|
||||
return organization;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.RecordDoesNotExist
|
||||
) {
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
throw error; // Re-throw any other errors
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import { prisma } from "../../__mocks__/database";
|
||||
import {
|
||||
// getFilteredMockResponses,
|
||||
getMockUpdateResponseInput,
|
||||
mockContact,
|
||||
mockDisplay,
|
||||
mockEnvironmentId,
|
||||
mockMeta,
|
||||
mockResponse,
|
||||
mockResponseData,
|
||||
mockResponseNote,
|
||||
// mockResponseWithMockPerson,
|
||||
mockSingleUseId,
|
||||
// mockSurvey,
|
||||
mockSurveyId,
|
||||
mockSurveySummaryOutput,
|
||||
mockTags,
|
||||
mockUserId,
|
||||
} from "./__mocks__/data.mock";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { testInputValidation } from "vitestSetup";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput } from "@formbricks/types/responses";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getSurveySummary } from "../../../../apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import {
|
||||
@@ -35,20 +31,9 @@ import {
|
||||
getResponseBySingleUseId,
|
||||
getResponseCountBySurveyId,
|
||||
getResponseDownloadUrl,
|
||||
getResponses,
|
||||
getResponsesByContactId,
|
||||
getResponsesByEnvironmentId,
|
||||
updateResponse,
|
||||
} from "../service";
|
||||
import { buildWhereClause } from "../utils";
|
||||
import { constantsForTests, mockEnvironment } from "./constants";
|
||||
|
||||
// vitest.mock("../../organization/service", async (methods) => {
|
||||
// return {
|
||||
// ...methods,
|
||||
// getOrganizationByEnvironmentId: vitest.fn(),
|
||||
// };
|
||||
// });
|
||||
|
||||
const expectedResponseWithoutPerson: TResponse = {
|
||||
...mockResponse,
|
||||
@@ -56,26 +41,6 @@ const expectedResponseWithoutPerson: TResponse = {
|
||||
tags: mockTags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
const expectedResponseWithPerson: TResponse = {
|
||||
...mockResponse,
|
||||
contact: mockContact,
|
||||
tags: mockTags?.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
const mockResponseInputWithoutUserId: TResponseInput = {
|
||||
environmentId: mockEnvironmentId,
|
||||
surveyId: mockSurveyId,
|
||||
singleUseId: mockSingleUseId,
|
||||
finished: constantsForTests.boolean,
|
||||
data: {},
|
||||
meta: mockMeta,
|
||||
};
|
||||
|
||||
const mockResponseInputWithUserId: TResponseInput = {
|
||||
...mockResponseInputWithoutUserId,
|
||||
userId: mockUserId,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error
|
||||
prisma.response.create.mockImplementation(async (args) => {
|
||||
@@ -126,47 +91,6 @@ beforeEach(() => {
|
||||
prisma.response.aggregate.mockResolvedValue({ _count: { id: 1 } });
|
||||
});
|
||||
|
||||
// describe("Tests for getResponsesByPersonId", () => {
|
||||
// describe("Happy Path", () => {
|
||||
// it("Returns all responses associated with a given person ID", async () => {
|
||||
// prisma.response.findMany.mockResolvedValue([mockResponseWithMockPerson]);
|
||||
|
||||
// const responses = await getResponsesByContactId(mockContact.id);
|
||||
// expect(responses).toEqual([expectedResponseWithPerson]);
|
||||
// });
|
||||
|
||||
// it("Returns an empty array when no responses are found for the given person ID", async () => {
|
||||
// prisma.response.findMany.mockResolvedValue([]);
|
||||
|
||||
// const responses = await getResponsesByContactId(mockContact.id);
|
||||
// expect(responses).toEqual([]);
|
||||
// });
|
||||
// });
|
||||
|
||||
// describe("Sad Path", () => {
|
||||
// testInputValidation(getResponsesByContactId, "123#", 1);
|
||||
|
||||
// it("Throws a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
|
||||
// const mockErrorMessage = "Mock error message";
|
||||
// const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
// code: "P2002",
|
||||
// clientVersion: "0.0.1",
|
||||
// });
|
||||
|
||||
// prisma.response.findMany.mockRejectedValue(errToThrow);
|
||||
|
||||
// await expect(getResponsesByContactId(mockContact.id)).rejects.toThrow(DatabaseError);
|
||||
// });
|
||||
|
||||
// it("Throws a generic Error for unexpected exceptions", async () => {
|
||||
// const mockErrorMessage = "Mock error message";
|
||||
// prisma.response.findMany.mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
// await expect(getResponsesByContactId(mockContact.id)).rejects.toThrow(Error);
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
||||
describe("Tests for getResponsesBySingleUseId", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Retrieves responses linked to a specific single-use ID", async () => {
|
||||
@@ -181,7 +105,7 @@ describe("Tests for getResponsesBySingleUseId", () => {
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
@@ -219,7 +143,7 @@ describe("Tests for getResponse service", () => {
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
@@ -237,121 +161,6 @@ describe("Tests for getResponse service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// describe("Tests for getResponses service", () => {
|
||||
// describe("Happy Path", () => {
|
||||
// it("Fetches first 10 responses for a given survey ID", async () => {
|
||||
// prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
|
||||
|
||||
// const response = await getResponses(mockSurveyId, 1, 10);
|
||||
// expect(response).toEqual([expectedResponseWithoutPerson]);
|
||||
// });
|
||||
// });
|
||||
|
||||
// describe("Tests for getResponses service with filters", () => {
|
||||
// describe("Happy Path", () => {
|
||||
// // it("Fetches all responses for a given survey ID with basic filters", async () => {
|
||||
// // const whereClause = buildWhereClause(mockSurvey, { finished: true });
|
||||
// // let expectedWhereClause: Prisma.ResponseWhereInput | undefined = {};
|
||||
|
||||
// // // @ts-expect-error
|
||||
// // prisma.response.findMany.mockImplementation(async (args) => {
|
||||
// // expectedWhereClause = args?.where;
|
||||
// // return getFilteredMockResponses({ finished: true }, false);
|
||||
// // });
|
||||
|
||||
// // prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
|
||||
// // const response = await getResponses(mockSurveyId, 1, undefined, { finished: true });
|
||||
|
||||
// // expect(expectedWhereClause).toEqual({ surveyId: mockSurveyId, ...whereClause });
|
||||
// // expect(response).toEqual(getFilteredMockResponses({ finished: true }));
|
||||
// // });
|
||||
|
||||
// it("Fetches all responses for a given survey ID with complex filters", async () => {
|
||||
// const criteria: TResponseFilterCriteria = {
|
||||
// finished: false,
|
||||
// data: {
|
||||
// hagrboqlnynmxh3obl1wvmtl: {
|
||||
// op: "equals",
|
||||
// value: "Google Search",
|
||||
// },
|
||||
// uvy0fa96e1xpd10nrj1je662: {
|
||||
// op: "includesOne",
|
||||
// value: ["Sun ☀️"],
|
||||
// },
|
||||
// },
|
||||
// tags: {
|
||||
// applied: ["tag1"],
|
||||
// notApplied: ["tag4"],
|
||||
// },
|
||||
// contactAttributes: {
|
||||
// "Init Attribute 2": {
|
||||
// op: "equals",
|
||||
// value: "four",
|
||||
// },
|
||||
// },
|
||||
// };
|
||||
// const whereClause = buildWhereClause(mockSurvey, criteria);
|
||||
// let expectedWhereClause: Prisma.ResponseWhereInput | undefined = {};
|
||||
|
||||
// // @ts-expect-error
|
||||
// prisma.response.findMany.mockImplementation(async (args) => {
|
||||
// expectedWhereClause = args?.where;
|
||||
// return getFilteredMockResponses(criteria, false);
|
||||
// });
|
||||
// prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
|
||||
// const response = await getResponses(mockSurveyId, 1, undefined, criteria);
|
||||
|
||||
// expect(expectedWhereClause).toEqual({ surveyId: mockSurveyId, ...whereClause });
|
||||
// expect(response).toEqual(getFilteredMockResponses(criteria));
|
||||
// });
|
||||
// });
|
||||
|
||||
// describe("Sad Path", () => {
|
||||
// it("Throws an error when the where clause is different and the data is matched when filters are different.", async () => {
|
||||
// const whereClause = buildWhereClause(mockSurvey, { finished: true });
|
||||
// let expectedWhereClause: Prisma.ResponseWhereInput | undefined = {};
|
||||
|
||||
// // @ts-expect-error
|
||||
// prisma.response.findMany.mockImplementation(async (args) => {
|
||||
// expectedWhereClause = args?.where;
|
||||
|
||||
// return getFilteredMockResponses({ finished: true });
|
||||
// });
|
||||
// prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
|
||||
// const response = await getResponses(mockSurveyId, 1, undefined, { finished: true });
|
||||
|
||||
// expect(expectedWhereClause).not.toEqual(whereClause);
|
||||
// expect(response).not.toEqual(getFilteredMockResponses({ finished: false }));
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
||||
// describe("Sad Path", () => {
|
||||
// testInputValidation(getResponses, mockSurveyId, "1");
|
||||
|
||||
// it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
// const mockErrorMessage = "Mock error message";
|
||||
// const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
// code: "P2002",
|
||||
// clientVersion: "0.0.1",
|
||||
// });
|
||||
|
||||
// prisma.response.findMany.mockRejectedValue(errToThrow);
|
||||
// prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
|
||||
|
||||
// await expect(getResponses(mockSurveyId)).rejects.toThrow(DatabaseError);
|
||||
// });
|
||||
|
||||
// it("Throws a generic Error for unexpected problems", async () => {
|
||||
// const mockErrorMessage = "Mock error message";
|
||||
// prisma.response.findMany.mockRejectedValue(new Error(mockErrorMessage));
|
||||
// prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
|
||||
|
||||
// await expect(getResponses(mockSurveyId)).rejects.toThrow(Error);
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
||||
describe("Tests for getSurveySummary service", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Returns a summary of the survey responses", async () => {
|
||||
@@ -370,7 +179,7 @@ describe("Tests for getSurveySummary service", () => {
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
@@ -432,7 +241,7 @@ describe("Tests for getResponseDownloadUrl service", () => {
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError, when the getResponseCountBySurveyId fails", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
|
||||
@@ -444,7 +253,7 @@ describe("Tests for getResponseDownloadUrl service", () => {
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError, when the getResponses fails", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
@@ -480,7 +289,7 @@ describe("Tests for getResponsesByEnvironmentId", () => {
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
@@ -531,7 +340,7 @@ describe("Tests for updateResponse Service", () => {
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
@@ -565,7 +374,7 @@ describe("Tests for deleteResponse service", () => {
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { evaluateLogic } from "surveyLogic/utils";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { testInputValidation } from "vitestSetup";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getSurvey, getSurveyCount, getSurveys, getSurveysByActionClassId, updateSurvey } from "../service";
|
||||
import {
|
||||
@@ -183,7 +184,7 @@ describe("Tests for getSurvey", () => {
|
||||
it("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
prisma.survey.findUnique.mockRejectedValue(errToThrow);
|
||||
@@ -246,7 +247,7 @@ describe("Tests for getSurveys", () => {
|
||||
it("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
@@ -289,7 +290,7 @@ describe("Tests for updateSurvey", () => {
|
||||
it("should throw a DatabaseError error if there is a PrismaClientKnownRequestError", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: "P2002",
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
prisma.survey.findUnique.mockResolvedValueOnce(mockSurveyOutput);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbricks/types/user";
|
||||
@@ -111,7 +112,10 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
|
||||
|
||||
return updatedUser;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === PrismaErrorType.RecordDoesNotExist
|
||||
) {
|
||||
throw new ResourceNotFoundError("User", personId);
|
||||
}
|
||||
throw error; // Re-throw any other errors
|
||||
|
||||
Reference in New Issue
Block a user