diff --git a/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts index 9f299ee840..4e7ffb9a47 100644 --- a/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts +++ b/apps/web/app/api/v1/webhooks/[webhookId]/lib/webhook.ts @@ -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 => { 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}`); diff --git a/apps/web/app/api/v2/management/webhooks/[webhookId]/route.ts b/apps/web/app/api/v2/management/webhooks/[webhookId]/route.ts new file mode 100644 index 0000000000..6655f124cc --- /dev/null +++ b/apps/web/app/api/v2/management/webhooks/[webhookId]/route.ts @@ -0,0 +1,3 @@ +import { DELETE, GET, PUT } from "@/modules/api/v2/management/webhooks/[webhookId]/route"; + +export { GET, PUT, DELETE }; diff --git a/apps/web/app/api/v2/management/webhooks/route.ts b/apps/web/app/api/v2/management/webhooks/route.ts new file mode 100644 index 0000000000..d6497c990c --- /dev/null +++ b/apps/web/app/api/v2/management/webhooks/route.ts @@ -0,0 +1,3 @@ +import { GET, POST } from "@/modules/api/v2/management/webhooks/route"; + +export { GET, POST }; diff --git a/apps/web/modules/api/v2/management/auth/api-wrapper.ts b/apps/web/modules/api/v2/management/auth/api-wrapper.ts index 0ff89bbafb..53d7900fff 100644 --- a/apps/web/modules/api/v2/management/auth/api-wrapper.ts +++ b/apps/web/modules/api/v2/management/auth/api-wrapper.ts @@ -43,7 +43,7 @@ export const apiWrapper = async ({ }): Promise => { try { const authentication = await authenticateRequest(request); - if (!authentication.ok) throw authentication.error; + if (!authentication.ok) return handleApiError(request, authentication.error); let parsedInput: ParsedSchemas = {} as ParsedSchemas; @@ -53,7 +53,7 @@ export const apiWrapper = async ({ if (!bodyResult.success) { throw err({ - type: "forbidden", + type: "bad_request", details: formatZodError(bodyResult.error), }); } @@ -100,6 +100,6 @@ export const apiWrapper = async ({ request, }); } catch (err) { - return handleApiError(request, err); + return handleApiError(request, err.error); } }; diff --git a/apps/web/modules/api/v2/management/auth/authenticate-request.ts b/apps/web/modules/api/v2/management/auth/authenticate-request.ts index 7e6a1cacde..f0ec9e3165 100644 --- a/apps/web/modules/api/v2/management/auth/authenticate-request.ts +++ b/apps/web/modules/api/v2/management/auth/authenticate-request.ts @@ -8,6 +8,7 @@ export const authenticateRequest = async ( request: Request ): Promise> => { const apiKey = request.headers.get("x-api-key"); + if (apiKey) { const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey); if (!environmentIdResult.ok) { diff --git a/apps/web/modules/api/v2/management/lib/helper.ts b/apps/web/modules/api/v2/management/lib/helper.ts index 0b5d07e406..0e86d002e1 100644 --- a/apps/web/modules/api/v2/management/lib/helper.ts +++ b/apps/web/modules/api/v2/management/lib/helper.ts @@ -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> => { + 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]); +}; diff --git a/apps/web/modules/api/v2/management/lib/openapi.ts b/apps/web/modules/api/v2/management/lib/openapi.ts deleted file mode 100644 index f268bb2516..0000000000 --- a/apps/web/modules/api/v2/management/lib/openapi.ts +++ /dev/null @@ -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, - }, -}; diff --git a/apps/web/modules/api/v2/management/lib/services.ts b/apps/web/modules/api/v2/management/lib/services.ts index 1d1a769104..9420165725 100644 --- a/apps/web/modules/api/v2/management/lib/services.ts +++ b/apps/web/modules/api/v2/management/lib/services.ts @@ -41,3 +41,36 @@ export const fetchEnvironmentId = reactCache(async (id: string, isResponseId: bo } )() ); + +export const fetchEnvironmentIdFromSurveyIds = reactCache(async (surveyIds: string[]) => + cache( + async (): Promise> => { + 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)), + } + )() +); diff --git a/apps/web/modules/api/v2/management/lib/tests/helper.test.ts b/apps/web/modules/api/v2/management/lib/tests/helper.test.ts index 5b76f2360b..845c61cd15 100644 --- a/apps/web/modules/api/v2/management/lib/tests/helper.test.ts +++ b/apps/web/modules/api/v2/management/lib/tests/helper.test.ts @@ -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 }); + }); +}); diff --git a/apps/web/modules/api/v2/management/lib/tests/services.test.ts b/apps/web/modules/api/v2/management/lib/tests/services.test.ts index 9e22295f7a..02af5f1406 100644 --- a/apps/web/modules/api/v2/management/lib/tests/services.test.ts +++ b/apps/web/modules/api/v2/management/lib/tests/services.test.ts @@ -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"); + } + }); + }); }); diff --git a/apps/web/modules/api/v2/management/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/lib/tests/utils.test.ts index a748f15451..f189e4f76a 100644 --- a/apps/web/modules/api/v2/management/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/management/lib/tests/utils.test.ts @@ -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({}); + }); + }); +}); diff --git a/apps/web/modules/api/v2/management/lib/utils.ts b/apps/web/modules/api/v2/management/lib/utils.ts index 0d8195da8a..3e601de2cc 100644 --- a/apps/web/modules/api/v2/management/lib/utils.ts +++ b/apps/web/modules/api/v2/management/lib/utils.ts @@ -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(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(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; +} diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts index a957d09e3b..b13245d343 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/display.ts @@ -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 ({ @@ -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", diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts index b4a5717337..edd9fb78d6 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/response.test.ts @@ -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", diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts index 08a01513aa..90443a5202 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts @@ -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); }, }); diff --git a/apps/web/modules/api/v2/management/responses/lib/openapi.ts b/apps/web/modules/api/v2/management/responses/lib/openapi.ts index f562b1c3c6..e46da37627 100644 --- a/apps/web/modules/api/v2/management/responses/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/responses/lib/openapi.ts @@ -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), }, }, }, diff --git a/apps/web/modules/api/v2/management/responses/lib/organization.ts b/apps/web/modules/api/v2/management/responses/lib/organization.ts index 9ca2a06cef..334f892e02 100644 --- a/apps/web/modules/api/v2/management/responses/lib/organization.ts +++ b/apps/web/modules/api/v2/management/responses/lib/organization.ts @@ -48,7 +48,7 @@ export const getOrganizationIdFromEnvironmentId = reactCache(async (environmentI export const getOrganizationBilling = reactCache(async (organizationId: string) => cache( - async (): Promise, ApiErrorResponseV2>> => { + async (): Promise> => { 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> => { 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 diff --git a/apps/web/modules/api/v2/management/responses/lib/response.ts b/apps/web/modules/api/v2/management/responses/lib/response.ts index f48eb413d8..6e0ce2516d 100644 --- a/apps/web/modules/api/v2/management/responses/lib/response.ts +++ b/apps/web/modules/api/v2/management/responses/lib/response.ts @@ -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: { diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts index 3dc84295d0..d908a5d1b4 100644 --- a/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts +++ b/apps/web/modules/api/v2/management/responses/lib/tests/organization.test.ts @@ -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); } }); diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts index d225af34a1..524749896c 100644 --- a/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts +++ b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts @@ -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)); diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts index 088c955350..6ee8be7731 100644 --- a/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts +++ b/apps/web/modules/api/v2/management/responses/lib/tests/utils.test.ts @@ -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({ + where: { + survey: { environmentId: "env-id" }, + surveyId: "test", + }, + }), + { someFilter: true } + ); + expect(result).toEqual({ where: { combined: true } }); }); }); diff --git a/apps/web/modules/api/v2/management/responses/lib/utils.ts b/apps/web/modules/api/v2/management/responses/lib/utils.ts index 536022d508..5fa258311c 100644 --- a/apps/web/modules/api/v2/management/responses/lib/utils.ts +++ b/apps/web/modules/api/v2/management/responses/lib/utils.ts @@ -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(query, baseFilter); + } + return query; }; diff --git a/apps/web/modules/api/v2/management/responses/types/responses.ts b/apps/web/modules/api/v2/management/responses/types/responses.ts index b2161aa953..96a1655929 100644 --- a/apps/web/modules/api/v2/management/responses/types/responses.ts +++ b/apps/web/modules/api/v2/management/responses/types/responses.ts @@ -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; @@ -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; diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts new file mode 100644 index 0000000000..6d0c6e2615 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts @@ -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), + }, + }, + }, + }, +}; diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts new file mode 100644 index 0000000000..a6b335ba5e --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock.ts @@ -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", +}); diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts new file mode 100644 index 0000000000..858f7fc74c --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts @@ -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; + + 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"); + } + }); +}); diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts new file mode 100644 index 0000000000..519cc3a9a7 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts @@ -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> => { + 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 +): Promise> => { + 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> => { + 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 }], + }); + } +}; diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts new file mode 100644 index 0000000000..2c1fa0cb53 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts @@ -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); + }, + }); diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/types/webhooks.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/types/webhooks.ts new file mode 100644 index 0000000000..9bcc7a708a --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/types/webhooks.ts @@ -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.", +}); diff --git a/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts b/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts new file mode 100644 index 0000000000..92bac070d2 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts @@ -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, + }, +}; diff --git a/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts new file mode 100644 index 0000000000..1314708eaf --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/lib/tests/utils.test.ts @@ -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(); + }); +}); diff --git a/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts b/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts new file mode 100644 index 0000000000..b0e2104d9c --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts @@ -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"); + } + }); +}); diff --git a/apps/web/modules/api/v2/management/webhooks/lib/utils.ts b/apps/web/modules/api/v2/management/webhooks/lib/utils.ts new file mode 100644 index 0000000000..59716e4cd8 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/lib/utils.ts @@ -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(query, baseFilter); + } + + return query; +}; diff --git a/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts new file mode 100644 index 0000000000..7d9d15fbf3 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts @@ -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, 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> => { + 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 }], + }); + } +}; diff --git a/apps/web/modules/api/v2/management/webhooks/route.ts b/apps/web/modules/api/v2/management/webhooks/route.ts new file mode 100644 index 0000000000..994635e13e --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/route.ts @@ -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); + }, + }); diff --git a/apps/web/modules/api/v2/management/webhooks/types/webhooks.ts b/apps/web/modules/api/v2/management/webhooks/types/webhooks.ts new file mode 100644 index 0000000000..e049c92413 --- /dev/null +++ b/apps/web/modules/api/v2/management/webhooks/types/webhooks.ts @@ -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; + +export const ZWebhookInput = ZWebhook.pick({ + name: true, + url: true, + source: true, + environmentId: true, + triggers: true, + surveyIds: true, +}); + +export type TWebhookInput = z.infer; diff --git a/apps/web/modules/api/v2/openapi-document.ts b/apps/web/modules/api/v2/openapi-document.ts index 5581b16549..18d79a3d5c 100644 --- a/apps/web/modules/api/v2/openapi-document.ts +++ b/apps/web/modules/api/v2/openapi-document.ts @@ -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: [ diff --git a/apps/web/modules/api/v2/types/api-filter.ts b/apps/web/modules/api/v2/types/api-filter.ts new file mode 100644 index 0000000000..29fe9ab051 --- /dev/null +++ b/apps/web/modules/api/v2/types/api-filter.ts @@ -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; diff --git a/apps/web/modules/api/v2/types/openapi-response.ts b/apps/web/modules/api/v2/types/openapi-response.ts new file mode 100644 index 0000000000..50c2e8445a --- /dev/null +++ b/apps/web/modules/api/v2/types/openapi-response.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export function responseWithMetaSchema(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>(schema: T) { + return schema.partial(); +} diff --git a/apps/web/modules/auth/lib/user.test.ts b/apps/web/modules/auth/lib/user.test.ts index 1cbbd63dc8..10a7f6b984 100644 --- a/apps/web/modules/auth/lib/user.test.ts +++ b/apps/web/modules/auth/lib/user.test.ts @@ -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); diff --git a/apps/web/modules/auth/lib/user.ts b/apps/web/modules/auth/lib/user.ts index ab47f40e6e..b5d647dc9e 100644 --- a/apps/web/modules/auth/lib/user.ts +++ b/apps/web/modules/auth/lib/user.ts @@ -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"); } diff --git a/apps/web/modules/ee/role-management/lib/invite.ts b/apps/web/modules/ee/role-management/lib/invite.ts index 4b5f0124ef..d00e63f3b1 100644 --- a/apps/web/modules/ee/role-management/lib/invite.ts +++ b/apps/web/modules/ee/role-management/lib/invite.ts @@ -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 => { @@ -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 diff --git a/apps/web/modules/ee/role-management/lib/membership.ts b/apps/web/modules/ee/role-management/lib/membership.ts index ee0803f21c..d631455cd0 100644 --- a/apps/web/modules/ee/role-management/lib/membership.ts +++ b/apps/web/modules/ee/role-management/lib/membership.ts @@ -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}`); } diff --git a/apps/web/modules/ee/whitelabel/email-customization/lib/organization.ts b/apps/web/modules/ee/whitelabel/email-customization/lib/organization.ts index 105be391f9..2fb163ec60 100644 --- a/apps/web/modules/ee/whitelabel/email-customization/lib/organization.ts +++ b/apps/web/modules/ee/whitelabel/email-customization/lib/organization.ts @@ -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); } diff --git a/apps/web/modules/integrations/webhooks/lib/webhook.ts b/apps/web/modules/integrations/webhooks/lib/webhook.ts index dcab09ce28..1eced5881b 100644 --- a/apps/web/modules/integrations/webhooks/lib/webhook.ts +++ b/apps/web/modules/integrations/webhooks/lib/webhook.ts @@ -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 => { 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}`); diff --git a/apps/web/modules/projects/settings/lib/project.ts b/apps/web/modules/projects/settings/lib/project.ts index d0733b3d21..933a7c50d7 100644 --- a/apps/web/modules/projects/settings/lib/project.ts +++ b/apps/web/modules/projects/settings/lib/project.ts @@ -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); diff --git a/apps/web/modules/survey/components/template-list/lib/user.ts b/apps/web/modules/survey/components/template-list/lib/user.ts index 6cf63a4138..8719847c1a 100644 --- a/apps/web/modules/survey/components/template-list/lib/user.ts +++ b/apps/web/modules/survey/components/template-list/lib/user.ts @@ -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 => { @@ -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 diff --git a/apps/web/modules/survey/editor/lib/action-class.ts b/apps/web/modules/survey/editor/lib/action-class.ts index e84e0e0535..0962aba29a 100644 --- a/apps/web/modules/survey/editor/lib/action-class.ts +++ b/apps/web/modules/survey/editor/lib/action-class.ts @@ -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` ); diff --git a/apps/web/playwright/api/constants.ts b/apps/web/playwright/api/constants.ts index 6f59359be4..904500b1e6 100644 --- a/apps/web/playwright/api/constants.ts +++ b/apps/web/playwright/api/constants.ts @@ -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`; diff --git a/apps/web/playwright/api/management/responses.spec.ts b/apps/web/playwright/api/management/response.spec.ts similarity index 100% rename from apps/web/playwright/api/management/responses.spec.ts rename to apps/web/playwright/api/management/response.spec.ts diff --git a/apps/web/playwright/api/management/webhook.spec.ts b/apps/web/playwright/api/management/webhook.spec.ts new file mode 100644 index 0000000000..d0b86c7296 --- /dev/null +++ b/apps/web/playwright/api/management/webhook.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/web/test-results/.last-run.json b/apps/web/test-results/.last-run.json new file mode 100644 index 0000000000..f341f7f7cf --- /dev/null +++ b/apps/web/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "failedTests": [], + "status": "failed" +} diff --git a/docs/api-v2-reference/openapi.yml b/docs/api-v2-reference/openapi.yml index 813470d338..7ef49a39fd 100644 --- a/docs/api-v2-reference/openapi.yml +++ b/docs/api-v2-reference/openapi.yml @@ -17,10 +17,8 @@ tags: description: Operations for managing contact attributes keys. - name: Management API > Surveys description: Operations for managing surveys. - - name: Management API > Storage - description: Operations for managing storage. - - name: Client API > File Upload - description: Operations for uploading files. + - name: Management API > Webhooks + description: Operations for managing webhooks. paths: /responses/{responseId}: put: @@ -285,8 +283,7 @@ paths: /{environmentId}/storage: post: summary: Upload Private File - description: > - API endpoint for uploading private files. Uploaded files are kept + description: API endpoint for uploading private files. Uploaded files are kept private so that only users with access to the specified environment can retrieve them. The endpoint validates the survey ID, file name, and file type from the request body, and returns a signed URL for S3 uploads @@ -396,12 +393,11 @@ paths: /{environmentId}/storage/local: post: summary: Upload Private File to Local Storage - description: > - API endpoint for uploading private files to local storage. The request - must include a valid signature, UUID, and timestamp to verify the - upload. The file is provided as a Base64 encoded string in the request - body. The "Content-Type" header must be set to a valid MIME type, and - the file data must be a valid file object (buffer). + description: API endpoint for uploading private files to local storage. The + request must include a valid signature, UUID, and timestamp to verify + the upload. The file is provided as a Base64 encoded string in the + request body. The "Content-Type" header must be set to a valid MIME + type, and the file data must be a valid file object (buffer). tags: - Client API > File Upload parameters: @@ -438,9 +434,8 @@ paths: description: Timestamp used in the signature validation. fileBase64String: type: string - description: > - Base64 encoded string of the file. It should include data - type information, e.g. + description: Base64 encoded string of the file. It should include data type + information, e.g. "data:;base64,". required: - surveyId @@ -450,14 +445,14 @@ paths: - uuid - timestamp - fileBase64String - example: - surveyId: survey123 - fileName: example.jpg - fileType: image/jpeg - signature: signedSignatureValue - uuid: uniqueUuidValue - timestamp: "1627891234567" - fileBase64String: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/... + example: + surveyId: survey123 + fileName: example.jpg + fileType: image/jpeg + signature: signedSignatureValue + uuid: uniqueUuidValue + timestamp: "1627891234567" + fileBase64String: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/... responses: "200": description: OK - File uploaded successfully. @@ -469,8 +464,8 @@ paths: message: type: string description: Success message. - example: - message: File uploaded successfully + example: + message: File uploaded successfully "400": description: Bad Request - One or more required fields are missing or the file is too large. @@ -482,8 +477,8 @@ paths: error: type: string description: Detailed error message. - example: - error: fileName is required + example: + error: fileName is required "401": description: Unauthorized - Signature validation failed or required signature fields are missing. @@ -495,8 +490,8 @@ paths: error: type: string description: Detailed error message. - example: - error: Unauthorized + example: + error: Unauthorized "404": description: Not Found - The specified survey or organization does not exist. content: @@ -507,8 +502,8 @@ paths: error: type: string description: Detailed error message. - example: - error: Survey survey123 not found + example: + error: Survey survey123 not found "500": description: Internal Server Error - File upload failed. content: @@ -519,61 +514,11 @@ paths: error: type: string description: Detailed error message. - example: - error: File upload failed + example: + error: File upload failed servers: - url: https://app.formbricks.com/api/v2 description: Formbricks API Server - /{environmentId}/responses/{responseId}: - put: - description: Update an existing response for example when you want to mark a - response as finished or you want to change an existing response's value. - parameters: - - in: path - name: environmentId - required: true - schema: - type: string - - in: path - name: responseId - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - example: - data: - tcgls0063n8ri7dtrbnepcmz: Who? Who? Who? - finished: true - type: object - responses: - "200": - content: - application/json: - example: - data: {} - schema: - type: object - description: OK - "404": - content: - application/json: - example: - code: not_found - details: - resource_type: Response - message: Response not found - schema: - type: object - description: Not Found - summary: Update Response - tags: - - Client API > Response - servers: - - url: https://app.formbricks.com/api/v2/client - description: Formbricks Client /responses: get: security: @@ -641,7 +586,149 @@ paths: schema: type: array items: - $ref: "#/components/schemas/response" + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: string + description: The ID of the response + createdAt: + type: string + description: The date and time the response was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the response was last updated + example: 2021-01-01T00:00:00.000Z + finished: + type: boolean + description: Whether the response is finished + example: true + surveyId: + type: string + description: The ID of the survey + contactId: + type: + - string + - "null" + description: The ID of the contact + endingId: + type: + - string + - "null" + description: The ID of the ending + data: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + - type: array + items: + type: string + - type: object + additionalProperties: + type: string + description: The data of the response + example: &a1 + question1: answer1 + question2: 2 + question3: + - answer3 + - answer4 + question4: + subquestion1: answer5 + variables: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + description: The variables of the response + example: &a2 + variable1: answer1 + variable2: 2 + ttc: + type: object + additionalProperties: + type: number + description: The TTC of the response + example: &a3 + question1: 10 + question2: 20 + meta: + type: object + properties: + source: + type: string + description: The source of the response + example: https://example.com + url: + type: string + description: The URL of the response + example: https://example.com + userAgent: + type: object + properties: + browser: + type: string + os: + type: string + device: + type: string + country: + type: string + action: + type: string + description: The meta data of the response + example: &a4 + source: https://example.com + url: https://example.com + userAgent: + browser: Chrome + os: Windows + device: Desktop + country: US + action: click + contactAttributes: + type: + - object + - "null" + additionalProperties: + type: string + description: The attributes of the contact + example: &a5 + attribute1: value1 + attribute2: value2 + singleUseId: + type: + - string + - "null" + description: The single use ID of the response + language: + type: + - string + - "null" + description: The language of the response + example: en + displayId: + type: + - string + - "null" + description: The display ID of the response + meta: + type: object + properties: + total: + type: number + limit: + type: number + offset: + type: number post: security: - apiKeyAuth: [] @@ -656,14 +743,216 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/responseCreate" + type: object + properties: + createdAt: + type: string + description: The date and time the response was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the response was last updated + example: 2021-01-01T00:00:00.000Z + surveyId: + type: string + description: The ID of the survey + displayId: + type: + - string + - "null" + description: The display ID of the response + singleUseId: + type: + - string + - "null" + description: The single use ID of the response + finished: + type: boolean + description: Whether the response is finished + example: true + endingId: + type: + - string + - "null" + description: The ID of the ending + language: + type: + - string + - "null" + description: The language of the response + example: en + data: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + - type: array + items: + type: string + - type: object + additionalProperties: + type: string + description: The data of the response + example: *a1 + variables: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + description: The variables of the response + example: *a2 + ttc: + type: object + additionalProperties: + type: number + description: The TTC of the response + example: *a3 + meta: + type: object + properties: + source: + type: string + description: The source of the response + example: https://example.com + url: + type: string + description: The URL of the response + example: https://example.com + userAgent: + type: object + properties: + browser: + type: string + os: + type: string + device: + type: string + country: + type: string + action: + type: string + description: The meta data of the response + example: *a4 + required: + - surveyId + - finished + - data responses: "201": description: Response created successfully. content: application/json: schema: - $ref: "#/components/schemas/response" + type: object + properties: + id: + type: string + description: The ID of the response + createdAt: + type: string + description: The date and time the response was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the response was last updated + example: 2021-01-01T00:00:00.000Z + finished: + type: boolean + description: Whether the response is finished + example: true + surveyId: + type: string + description: The ID of the survey + contactId: + type: + - string + - "null" + description: The ID of the contact + endingId: + type: + - string + - "null" + description: The ID of the ending + data: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + - type: array + items: + type: string + - type: object + additionalProperties: + type: string + description: The data of the response + example: *a1 + variables: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + description: The variables of the response + example: *a2 + ttc: + type: object + additionalProperties: + type: number + description: The TTC of the response + example: *a3 + meta: + type: object + properties: + source: + type: string + description: The source of the response + example: https://example.com + url: + type: string + description: The URL of the response + example: https://example.com + userAgent: + type: object + properties: + browser: + type: string + os: + type: string + device: + type: string + country: + type: string + action: + type: string + description: The meta data of the response + example: *a4 + contactAttributes: + type: + - object + - "null" + additionalProperties: + type: string + description: The attributes of the contact + example: *a5 + singleUseId: + type: + - string + - "null" + description: The single use ID of the response + language: + type: + - string + - "null" + description: The language of the response + example: en + displayId: + type: + - string + - "null" + description: The display ID of the response /responses/{id}: get: security: @@ -686,7 +975,114 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/response" + type: object + properties: + id: + type: string + description: The ID of the response + createdAt: + type: string + description: The date and time the response was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the response was last updated + example: 2021-01-01T00:00:00.000Z + finished: + type: boolean + description: Whether the response is finished + example: true + surveyId: + type: string + description: The ID of the survey + contactId: + type: + - string + - "null" + description: The ID of the contact + endingId: + type: + - string + - "null" + description: The ID of the ending + data: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + - type: array + items: + type: string + - type: object + additionalProperties: + type: string + description: The data of the response + example: *a1 + variables: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + description: The variables of the response + example: *a2 + ttc: + type: object + additionalProperties: + type: number + description: The TTC of the response + example: *a3 + meta: + type: object + properties: + source: + type: string + description: The source of the response + example: https://example.com + url: + type: string + description: The URL of the response + example: https://example.com + userAgent: + type: object + properties: + browser: + type: string + os: + type: string + device: + type: string + country: + type: string + action: + type: string + description: The meta data of the response + example: *a4 + contactAttributes: + type: + - object + - "null" + additionalProperties: + type: string + description: The attributes of the contact + example: *a5 + singleUseId: + type: + - string + - "null" + description: The single use ID of the response + language: + type: + - string + - "null" + description: The language of the response + example: en + displayId: + type: + - string + - "null" + description: The display ID of the response put: security: - apiKeyAuth: [] @@ -791,7 +1187,114 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/response" + type: object + properties: + id: + type: string + description: The ID of the response + createdAt: + type: string + description: The date and time the response was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the response was last updated + example: 2021-01-01T00:00:00.000Z + finished: + type: boolean + description: Whether the response is finished + example: true + surveyId: + type: string + description: The ID of the survey + contactId: + type: + - string + - "null" + description: The ID of the contact + endingId: + type: + - string + - "null" + description: The ID of the ending + data: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + - type: array + items: + type: string + - type: object + additionalProperties: + type: string + description: The data of the response + example: *a1 + variables: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + description: The variables of the response + example: *a2 + ttc: + type: object + additionalProperties: + type: number + description: The TTC of the response + example: *a3 + meta: + type: object + properties: + source: + type: string + description: The source of the response + example: https://example.com + url: + type: string + description: The URL of the response + example: https://example.com + userAgent: + type: object + properties: + browser: + type: string + os: + type: string + device: + type: string + country: + type: string + action: + type: string + description: The meta data of the response + example: *a4 + contactAttributes: + type: + - object + - "null" + additionalProperties: + type: string + description: The attributes of the contact + example: *a5 + singleUseId: + type: + - string + - "null" + description: The single use ID of the response + language: + type: + - string + - "null" + description: The language of the response + example: en + displayId: + type: + - string + - "null" + description: The display ID of the response delete: security: - apiKeyAuth: [] @@ -813,7 +1316,114 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/response" + type: object + properties: + id: + type: string + description: The ID of the response + createdAt: + type: string + description: The date and time the response was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the response was last updated + example: 2021-01-01T00:00:00.000Z + finished: + type: boolean + description: Whether the response is finished + example: true + surveyId: + type: string + description: The ID of the survey + contactId: + type: + - string + - "null" + description: The ID of the contact + endingId: + type: + - string + - "null" + description: The ID of the ending + data: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + - type: array + items: + type: string + - type: object + additionalProperties: + type: string + description: The data of the response + example: *a1 + variables: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + description: The variables of the response + example: *a2 + ttc: + type: object + additionalProperties: + type: number + description: The TTC of the response + example: *a3 + meta: + type: object + properties: + source: + type: string + description: The source of the response + example: https://example.com + url: + type: string + description: The URL of the response + example: https://example.com + userAgent: + type: object + properties: + browser: + type: string + os: + type: string + device: + type: string + country: + type: string + action: + type: string + description: The meta data of the response + example: *a4 + contactAttributes: + type: + - object + - "null" + additionalProperties: + type: string + description: The attributes of the contact + example: *a5 + singleUseId: + type: + - string + - "null" + description: The single use ID of the response + language: + type: + - string + - "null" + description: The language of the response + example: en + displayId: + type: + - string + - "null" + description: The display ID of the response /contacts: get: security: @@ -1018,7 +1628,27 @@ paths: schema: type: array items: - $ref: "#/components/schemas/contactAttribute" + type: object + properties: + id: + type: string + createdAt: + type: string + updatedAt: + type: string + attributeKeyId: + type: string + contactId: + type: string + value: + type: string + required: + - id + - createdAt + - updatedAt + - attributeKeyId + - contactId + - value post: security: - apiKeyAuth: [] @@ -1163,7 +1793,44 @@ paths: schema: type: array items: - $ref: "#/components/schemas/contactAttributeKey" + type: object + properties: + id: + type: string + createdAt: + type: string + updatedAt: + type: string + isUnique: + type: boolean + default: false + key: + type: string + name: + type: + - string + - "null" + description: + type: + - string + - "null" + type: + type: string + enum: + - default + - custom + environmentId: + type: string + required: + - id + - createdAt + - updatedAt + - isUnique + - key + - name + - description + - type + - environmentId post: security: - apiKeyAuth: [] @@ -1422,6 +2089,453 @@ paths: application/json: schema: $ref: "#/components/schemas/survey" + /webhooks: + get: + security: + - apiKeyAuth: [] + operationId: getWebhooks + summary: Get webhooks + description: Gets webhooks from the database. + tags: + - Management API > Webhooks + parameters: + - in: query + name: limit + schema: + type: number + minimum: 1 + maximum: 100 + default: 10 + - in: query + name: skip + schema: + type: number + minimum: 0 + default: 0 + - in: query + name: sortBy + schema: + type: string + enum: + - createdAt + - updatedAt + default: createdAt + - in: query + name: order + schema: + type: string + enum: + - asc + - desc + default: desc + - in: query + name: startDate + schema: + type: string + required: true + - in: query + name: endDate + schema: + type: string + required: true + - in: query + name: surveyIds + schema: + type: array + items: + type: string + required: true + responses: + "200": + description: Webhooks retrieved successfully. + content: + application/json: + schema: + type: array + items: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: string + description: The ID of the webhook + name: + type: + - string + - "null" + description: The name of the webhook + createdAt: + type: string + description: The date and time the webhook was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the webhook was last updated + example: 2021-01-01T00:00:00.000Z + url: + type: string + format: uri + description: The URL of the webhook + source: + type: string + enum: &a6 + - user + - zapier + - make + - n8n + description: The source of the webhook + environmentId: + type: string + description: The ID of the environment + triggers: + type: array + items: + type: string + enum: &a7 + - responseFinished + - responseCreated + - responseUpdated + description: The triggers of the webhook + surveyIds: + type: array + items: + type: string + description: "The IDs of the surveys " + meta: + type: object + properties: + total: + type: number + limit: + type: number + offset: + type: number + post: + security: + - apiKeyAuth: [] + 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: + type: object + properties: + name: + type: + - string + - "null" + description: The name of the webhook + url: + type: string + format: uri + description: The URL of the webhook + source: + type: string + enum: *a6 + description: The source of the webhook + environmentId: + type: string + description: The ID of the environment + triggers: + type: array + items: + type: string + enum: *a7 + description: The triggers of the webhook + surveyIds: + type: array + items: + type: string + description: "The IDs of the surveys " + required: + - name + - url + - source + - environmentId + - triggers + - surveyIds + responses: + "201": + description: Webhook created successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the webhook + name: + type: + - string + - "null" + description: The name of the webhook + createdAt: + type: string + description: The date and time the webhook was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the webhook was last updated + example: 2021-01-01T00:00:00.000Z + url: + type: string + format: uri + description: The URL of the webhook + source: + type: string + enum: *a6 + description: The source of the webhook + environmentId: + type: string + description: The ID of the environment + triggers: + type: array + items: + type: string + enum: *a7 + description: The triggers of the webhook + surveyIds: + type: array + items: + type: string + description: "The IDs of the surveys " + /webhooks/{webhookId}: + get: + security: + - apiKeyAuth: [] + operationId: getWebhook + summary: Get a webhook + description: Gets a webhook from the database. + tags: + - Management API > Webhooks + parameters: + - in: path + name: id + description: The ID of the webhook + schema: + $ref: "#/components/schemas/webhookId" + required: true + responses: + "200": + description: Webhook retrieved successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the webhook + name: + type: + - string + - "null" + description: The name of the webhook + createdAt: + type: string + description: The date and time the webhook was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the webhook was last updated + example: 2021-01-01T00:00:00.000Z + url: + type: string + format: uri + description: The URL of the webhook + source: + type: string + enum: *a6 + description: The source of the webhook + environmentId: + type: string + description: The ID of the environment + triggers: + type: array + items: + type: string + enum: *a7 + description: The triggers of the webhook + surveyIds: + type: array + items: + type: string + description: "The IDs of the surveys " + put: + security: + - apiKeyAuth: [] + operationId: updateWebhook + summary: Update a webhook + description: Updates a webhook in the database. + tags: + - Management API > Webhooks + parameters: + - in: path + name: id + description: The ID of the webhook + schema: + $ref: "#/components/schemas/webhookId" + required: true + requestBody: + required: true + description: The webhook to update + content: + application/json: + schema: + type: object + properties: + name: + type: + - string + - "null" + description: The name of the webhook + url: + type: string + format: uri + description: The URL of the webhook + source: + type: string + enum: *a6 + description: The source of the webhook + environmentId: + type: string + description: The ID of the environment + triggers: + type: array + items: + type: string + enum: *a7 + description: The triggers of the webhook + surveyIds: + type: array + items: + type: string + description: "The IDs of the surveys " + required: + - name + - url + - source + - environmentId + - triggers + - surveyIds + responses: + "200": + description: Webhook updated successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the webhook + name: + type: + - string + - "null" + description: The name of the webhook + createdAt: + type: string + description: The date and time the webhook was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the webhook was last updated + example: 2021-01-01T00:00:00.000Z + url: + type: string + format: uri + description: The URL of the webhook + source: + type: string + enum: *a6 + description: The source of the webhook + environmentId: + type: string + description: The ID of the environment + triggers: + type: array + items: + type: string + enum: *a7 + description: The triggers of the webhook + surveyIds: + type: array + items: + type: string + description: "The IDs of the surveys " + delete: + security: + - apiKeyAuth: [] + operationId: deleteWebhook + summary: Delete a webhook + description: Deletes a webhook from the database. + tags: + - Management API > Webhooks + parameters: + - in: path + name: id + description: The ID of the webhook + schema: + $ref: "#/components/schemas/webhookId" + required: true + responses: + "200": + description: Webhook deleted successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the webhook + name: + type: + - string + - "null" + description: The name of the webhook + createdAt: + type: string + description: The date and time the webhook was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the webhook was last updated + example: 2021-01-01T00:00:00.000Z + url: + type: string + format: uri + description: The URL of the webhook + source: + type: string + enum: *a6 + description: The source of the webhook + environmentId: + type: string + description: The ID of the environment + triggers: + type: array + items: + type: string + enum: *a7 + description: The triggers of the webhook + surveyIds: + type: array + items: + type: string + description: "The IDs of the surveys " components: securitySchemes: apiKeyAuth: @@ -1474,14 +2588,7 @@ components: additionalProperties: type: string description: The data of the response - example: &a2 - question1: answer1 - question2: 2 - question3: - - answer3 - - answer4 - question4: - subquestion1: answer5 + example: *a1 variables: type: object additionalProperties: @@ -1489,17 +2596,13 @@ components: - type: string - type: number description: The variables of the response - example: &a3 - variable1: answer1 - variable2: 2 + example: *a2 ttc: type: object additionalProperties: type: number description: The TTC of the response - example: &a4 - question1: 10 - question2: 20 + example: *a3 meta: type: object properties: @@ -1525,15 +2628,7 @@ components: action: type: string description: The meta data of the response - example: &a5 - source: https://example.com - url: https://example.com - userAgent: - browser: Chrome - os: Windows - device: Desktop - country: US - action: click + example: *a4 contactAttributes: type: - object @@ -1541,9 +2636,7 @@ components: additionalProperties: type: string description: The attributes of the contact - example: - attribute1: value1 - attribute2: value2 + example: *a5 singleUseId: type: - string @@ -1844,7 +2937,7 @@ components: required: - id - type - default: &a6 [] + default: &a9 [] description: The endings of the survey thankYouCard: type: @@ -1912,7 +3005,7 @@ components: description: Survey variables displayOption: type: string - enum: &a7 + enum: &a10 - displayOnce - displayMultiple - displaySome @@ -1991,7 +3084,7 @@ components: type: - string - "null" - enum: &a9 + enum: &a12 - bottomLeft - bottomRight - topLeft @@ -2146,13 +3239,13 @@ components: properties: linkSurveys: type: string - enum: &a1 + enum: &a8 - casual - straight - simple appSurveys: type: string - enum: *a1 + enum: *a8 required: - linkSurveys - appSurveys @@ -2169,7 +3262,7 @@ components: type: - string - "null" - enum: &a8 + enum: &a11 - animation - color - image @@ -2274,104 +3367,57 @@ components: - verifyEmail - displayPercentage - questions - responseCreate: + webhook: type: object properties: + id: + type: string + description: The ID of the webhook + name: + type: + - string + - "null" + description: The name of the webhook createdAt: type: string - description: The date and time the response was created + description: The date and time the webhook was created example: 2021-01-01T00:00:00.000Z updatedAt: type: string - description: The date and time the response was last updated + description: The date and time the webhook was last updated example: 2021-01-01T00:00:00.000Z - surveyId: + url: type: string - description: The ID of the survey - displayId: - type: - - string - - "null" - description: The display ID of the response - singleUseId: - type: - - string - - "null" - description: The single use ID of the response - finished: - type: boolean - description: Whether the response is finished - example: true - endingId: - type: - - string - - "null" - description: The ID of the ending - language: - type: - - string - - "null" - description: The language of the response - example: en - data: - type: object - additionalProperties: - anyOf: - - type: string - - type: number - - type: array - items: - type: string - - type: object - additionalProperties: - type: string - description: The data of the response - example: *a2 - variables: - type: object - additionalProperties: - anyOf: - - type: string - - type: number - description: The variables of the response - example: *a3 - ttc: - type: object - additionalProperties: - type: number - description: The TTC of the response - example: *a4 - meta: - type: object - properties: - source: - type: string - description: The source of the response - example: https://example.com - url: - type: string - description: The URL of the response - example: https://example.com - userAgent: - type: object - properties: - browser: - type: string - os: - type: string - device: - type: string - country: - type: string - action: - type: string - description: The meta data of the response - example: *a5 + format: uri + description: The URL of the webhook + source: + type: string + enum: *a6 + description: The source of the webhook + environmentId: + type: string + description: The ID of the environment + triggers: + type: array + items: + type: string + enum: *a7 + description: The triggers of the webhook + surveyIds: + type: array + items: + type: string + description: "The IDs of the surveys " required: - - surveyId - - finished - - data - description: A response to create + - id + - name + - createdAt + - updatedAt + - url + - source + - environmentId + - triggers + - surveyIds responseId: type: string description: The ID of the response @@ -2517,7 +3563,7 @@ components: required: - id - type - default: *a6 + default: *a9 description: The endings of the survey thankYouCard: type: @@ -2583,7 +3629,7 @@ components: description: Survey variables displayOption: type: string - enum: *a7 + enum: *a10 description: Display options for the survey recontactDays: type: @@ -2843,10 +3889,10 @@ components: properties: linkSurveys: type: string - enum: *a1 + enum: *a8 appSurveys: type: string - enum: *a1 + enum: *a8 required: - linkSurveys - appSurveys @@ -2863,7 +3909,7 @@ components: type: - string - "null" - enum: *a8 + enum: *a11 brightness: type: - number @@ -2896,7 +3942,7 @@ components: type: - string - "null" - enum: *a9 + enum: *a12 clickOutsideClose: type: - boolean @@ -2927,3 +3973,6 @@ components: surveyId: type: string description: The ID of the survey + webhookId: + type: string + description: The ID of the webhook diff --git a/packages/database/migration/20250324134815_set_webhook_updated_at_default/migration.sql b/packages/database/migration/20250324134815_set_webhook_updated_at_default/migration.sql new file mode 100644 index 0000000000..5b14772915 --- /dev/null +++ b/packages/database/migration/20250324134815_set_webhook_updated_at_default/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Webhook" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP; diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index d27fb82741..d0992cb188 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -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) diff --git a/packages/database/types/error.ts b/packages/database/types/error.ts new file mode 100644 index 0000000000..c9a98773a9 --- /dev/null +++ b/packages/database/types/error.ts @@ -0,0 +1,5 @@ +export enum PrismaErrorType { + UniqueConstraintViolation = "P2002", + RecordDoesNotExist = "P2015", + RelatedRecordDoesNotExist = "P2025", +} diff --git a/packages/database/zod/organizations.ts b/packages/database/zod/organizations.ts new file mode 100644 index 0000000000..a194d9a63f --- /dev/null +++ b/packages/database/zod/organizations.ts @@ -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, + isAIEnabled: z.boolean().default(false) as z.ZodType, +}) satisfies z.ZodType; diff --git a/packages/database/zod/webhooks.ts b/packages/database/zod/webhooks.ts index 959a3fd409..32950d6854 100644 --- a/packages/database/zod/webhooks.ts +++ b/packages/database/zod/webhooks.ts @@ -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; + +ZWebhook.openapi({ + ref: "webhook", + description: "A webhook", +}); diff --git a/packages/lib/actionClass/service.ts b/packages/lib/actionClass/service.ts index ec2ed407cf..50c6c87972 100644 --- a/packages/lib/actionClass/service.ts +++ b/packages/lib/actionClass/service.ts @@ -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` ); diff --git a/packages/lib/display/tests/display.test.ts b/packages/lib/display/tests/display.test.ts index 94816accf2..bf3b15a279 100644 --- a/packages/lib/display/tests/display.test.ts +++ b/packages/lib/display/tests/display.test.ts @@ -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", }); diff --git a/packages/lib/language/tests/language.unit.ts b/packages/lib/language/tests/language.unit.ts index 438f528aef..d678c173c2 100644 --- a/packages/lib/language/tests/language.unit.ts +++ b/packages/lib/language/tests/language.unit.ts @@ -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", }); diff --git a/packages/lib/organization/service.ts b/packages/lib/organization/service.ts index 31698d0d0c..7d42053fff 100644 --- a/packages/lib/organization/service.ts +++ b/packages/lib/organization/service.ts @@ -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 diff --git a/packages/lib/response/tests/response.test.ts b/packages/lib/response/tests/response.test.ts index 492dd6e74d..b64b3b5d85 100644 --- a/packages/lib/response/tests/response.test.ts +++ b/packages/lib/response/tests/response.test.ts @@ -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", }); diff --git a/packages/lib/survey/tests/survey.test.ts b/packages/lib/survey/tests/survey.test.ts index 95555ca362..277a7de4ce 100644 --- a/packages/lib/survey/tests/survey.test.ts +++ b/packages/lib/survey/tests/survey.test.ts @@ -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); diff --git a/packages/lib/user/service.ts b/packages/lib/user/service.ts index 9e44ea31d7..a801a34f5c 100644 --- a/packages/lib/user/service.ts +++ b/packages/lib/user/service.ts @@ -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