feat: Added Webhooks in Management API V2 (#4949)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
victorvhs017
2025-03-25 11:28:44 -03:00
committed by GitHub
parent afb39e4aba
commit 46f06f4c0e
68 changed files with 3029 additions and 709 deletions

View File

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

View File

@@ -0,0 +1,3 @@
import { DELETE, GET, PUT } from "@/modules/api/v2/management/webhooks/[webhookId]/route";
export { GET, PUT, DELETE };

View File

@@ -0,0 +1,3 @@
import { GET, POST } from "@/modules/api/v2/management/webhooks/route";
export { GET, POST };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);
},
});

View File

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

View 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,
},
};

View File

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

View File

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

View 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;
};

View 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 }],
});
}
};

View 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);
},
});

View File

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

View File

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

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

View 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();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,4 @@
{
"failedTests": [],
"status": "failed"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Webhook" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;

View File

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

View File

@@ -0,0 +1,5 @@
export enum PrismaErrorType {
UniqueConstraintViolation = "P2002",
RecordDoesNotExist = "P2015",
RelatedRecordDoesNotExist = "P2025",
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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