From 630e5489ec7c16537faea38d74acb2546a5bf7bd Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Wed, 23 Apr 2025 21:18:18 +0530 Subject: [PATCH] feat: Implement v2 management api endpoint for contact attribute keys (#5316) Co-authored-by: Victor Santos --- .../[contactAttributeKeyId]/route.ts | 7 + .../contact-attribute-keys/route.ts | 3 + apps/web/modules/api/v2/auth/api-wrapper.ts | 18 +- apps/web/modules/api/v2/lib/response.ts | 29 ++ .../modules/api/v2/lib/tests/response.test.ts | 36 +- .../lib/contact-attribute-key.ts | 183 ++++++++ .../[contactAttributeKeyId]/lib/openapi.ts | 60 +-- .../lib/tests/contact-attribute-key.test.ts | 222 +++++++++ .../[contactAttributeKeyId]/route.ts | 131 ++++++ .../types/contact-attribute-keys.ts | 28 ++ .../lib/contact-attribute-key.ts | 105 +++++ .../contact-attribute-keys/lib/openapi.ts | 13 +- .../lib/tests/contact-attribute-key.test.ts | 166 +++++++ .../lib/tests/utils.test.ts | 106 +++++ .../contact-attribute-keys/lib/utils.ts | 26 ++ .../contact-attribute-keys/route.ts | 73 +++ .../types/contact-attribute-keys.ts | 19 +- .../modules/api/v2/management/lib/utils.ts | 3 +- .../api/v2/management/responses/route.ts | 2 +- .../api/v2/management/webhooks/route.ts | 2 +- apps/web/modules/api/v2/openapi-document.ts | 4 +- .../[organizationId]/teams/route.ts | 2 +- .../[organizationId]/users/route.ts | 2 +- docs/api-v2-reference/openapi.yml | 435 ++++++++++++++++++ 24 files changed, 1619 insertions(+), 56 deletions(-) create mode 100644 apps/web/app/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts create mode 100644 apps/web/app/api/v2/management/contact-attribute-keys/route.ts create mode 100644 apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts create mode 100644 apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts create mode 100644 apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts create mode 100644 apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts create mode 100644 apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts create mode 100644 apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts create mode 100644 apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/utils.test.ts create mode 100644 apps/web/modules/api/v2/management/contact-attribute-keys/lib/utils.ts create mode 100644 apps/web/modules/api/v2/management/contact-attribute-keys/route.ts diff --git a/apps/web/app/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts b/apps/web/app/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts new file mode 100644 index 0000000000..6ae62003eb --- /dev/null +++ b/apps/web/app/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts @@ -0,0 +1,7 @@ +import { + DELETE, + GET, + PUT, +} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route"; + +export { GET, PUT, DELETE }; diff --git a/apps/web/app/api/v2/management/contact-attribute-keys/route.ts b/apps/web/app/api/v2/management/contact-attribute-keys/route.ts new file mode 100644 index 0000000000..2b7018e820 --- /dev/null +++ b/apps/web/app/api/v2/management/contact-attribute-keys/route.ts @@ -0,0 +1,3 @@ +import { GET, POST } from "@/modules/api/v2/management/contact-attribute-keys/route"; + +export { GET, POST }; diff --git a/apps/web/modules/api/v2/auth/api-wrapper.ts b/apps/web/modules/api/v2/auth/api-wrapper.ts index 1a4cc8d1c8..90a7a4cba7 100644 --- a/apps/web/modules/api/v2/auth/api-wrapper.ts +++ b/apps/web/modules/api/v2/auth/api-wrapper.ts @@ -21,11 +21,19 @@ export type ExtendedSchemas = { }; // Define a type that returns separate keys for each input type. -export type ParsedSchemas = { - body?: S extends { body: z.ZodObject } ? z.infer : undefined; - query?: S extends { query: z.ZodObject } ? z.infer : undefined; - params?: S extends { params: z.ZodObject } ? z.infer : undefined; -}; +// It uses mapped types to create a new type based on the input schemas. +// It checks if each schema is defined and if it is a ZodObject, then infers the type from it. +// It also uses conditional types to ensure that the keys are only included if the schema is defined and valid. +// This allows for more flexibility and type safety when working with the input schemas. +export type ParsedSchemas = S extends object + ? { + [K in keyof S as NonNullable extends z.ZodObject ? K : never]: NonNullable< + S[K] + > extends z.ZodObject + ? z.infer> + : never; + } + : {}; export const apiWrapper = async ({ request, diff --git a/apps/web/modules/api/v2/lib/response.ts b/apps/web/modules/api/v2/lib/response.ts index a378f75a36..4aa2689c90 100644 --- a/apps/web/modules/api/v2/lib/response.ts +++ b/apps/web/modules/api/v2/lib/response.ts @@ -260,6 +260,34 @@ const successResponse = ({ ); }; +export const createdResponse = ({ + data, + meta, + cors = false, + cache = "private, no-store", +}: { + data: Object; + meta?: Record; + cors?: boolean; + cache?: string; +}) => { + const headers = { + ...(cors && corsHeaders), + "Cache-Control": cache, + }; + + return Response.json( + { + data, + meta, + } as ApiSuccessResponse, + { + status: 201, + headers, + } + ); +}; + export const multiStatusResponse = ({ data, meta, @@ -298,5 +326,6 @@ export const responses = { tooManyRequestsResponse, internalServerErrorResponse, successResponse, + createdResponse, multiStatusResponse, }; diff --git a/apps/web/modules/api/v2/lib/tests/response.test.ts b/apps/web/modules/api/v2/lib/tests/response.test.ts index c5e5d233d9..a58f78fd4e 100644 --- a/apps/web/modules/api/v2/lib/tests/response.test.ts +++ b/apps/web/modules/api/v2/lib/tests/response.test.ts @@ -120,7 +120,7 @@ describe("API Responses", () => { }); test("include CORS headers when cors is true", () => { - const res = responses.unprocessableEntityResponse({ cors: true }); + const res = responses.unprocessableEntityResponse({ cors: true, details: [] }); expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); }); }); @@ -182,4 +182,38 @@ describe("API Responses", () => { expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); }); }); + + describe("createdResponse", () => { + test("return a success response with the provided data", async () => { + const data = { foo: "bar" }; + const meta = { page: 1 }; + const res = responses.createdResponse({ data, meta }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.data).toEqual(data); + expect(body.meta).toEqual(meta); + }); + + test("include CORS headers when cors is true", () => { + const data = { foo: "bar" }; + const res = responses.createdResponse({ data, cors: true }); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + }); + + describe("multiStatusResponse", () => { + test("return a 207 response with the provided data", async () => { + const data = { foo: "bar" }; + const res = responses.multiStatusResponse({ data }); + expect(res.status).toBe(207); + const body = await res.json(); + expect(body.data).toEqual(data); + }); + + test("include CORS headers when cors is true", () => { + const data = { foo: "bar" }; + const res = responses.multiStatusResponse({ data, cors: true }); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + }); }); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts new file mode 100644 index 0000000000..a9c25a5411 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts @@ -0,0 +1,183 @@ +import { cache } from "@/lib/cache"; +import { contactCache } from "@/lib/cache/contact"; +import { contactAttributeCache } from "@/lib/cache/contact-attribute"; +import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key"; +import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { ContactAttributeKey } from "@prisma/client"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { Result, err, ok } from "@formbricks/types/error-handlers"; + +export const getContactAttributeKey = reactCache(async (contactAttributeKeyId: string) => + cache( + async (): Promise> => { + try { + const contactAttributeKey = await prisma.contactAttributeKey.findUnique({ + where: { + id: contactAttributeKeyId, + }, + }); + + if (!contactAttributeKey) { + return err({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + + return ok(contactAttributeKey); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: error.message }], + }); + } + }, + [`management-getContactAttributeKey-${contactAttributeKeyId}`], + { + tags: [contactAttributeKeyCache.tag.byId(contactAttributeKeyId)], + } + )() +); + +export const updateContactAttributeKey = async ( + contactAttributeKeyId: string, + contactAttributeKeyInput: TContactAttributeKeyUpdateSchema +): Promise> => { + try { + const updatedKey = await prisma.contactAttributeKey.update({ + where: { + id: contactAttributeKeyId, + }, + data: contactAttributeKeyInput, + }); + + const associatedContactAttributes = await prisma.contactAttribute.findMany({ + where: { + attributeKeyId: updatedKey.id, + }, + select: { + id: true, + contactId: true, + }, + }); + + contactAttributeKeyCache.revalidate({ + id: contactAttributeKeyId, + environmentId: updatedKey.environmentId, + key: updatedKey.key, + }); + contactAttributeCache.revalidate({ + key: updatedKey.key, + environmentId: updatedKey.environmentId, + }); + + contactCache.revalidate({ + environmentId: updatedKey.environmentId, + }); + + associatedContactAttributes.forEach((contactAttribute) => { + contactAttributeCache.revalidate({ + contactId: contactAttribute.contactId, + }); + contactCache.revalidate({ + id: contactAttribute.contactId, + }); + }); + + return ok(updatedKey); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError) { + if ( + error.code === PrismaErrorType.RecordDoesNotExist || + error.code === PrismaErrorType.RelatedRecordDoesNotExist + ) { + return err({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + if (error.code === PrismaErrorType.UniqueConstraintViolation) { + return err({ + type: "conflict", + details: [ + { + field: "contactAttributeKey", + issue: `Contact attribute key with "${contactAttributeKeyInput.key}" already exists`, + }, + ], + }); + } + } + return err({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: error.message }], + }); + } +}; + +export const deleteContactAttributeKey = async ( + contactAttributeKeyId: string +): Promise> => { + try { + const deletedKey = await prisma.contactAttributeKey.delete({ + where: { + id: contactAttributeKeyId, + }, + }); + + const associatedContactAttributes = await prisma.contactAttribute.findMany({ + where: { + attributeKeyId: deletedKey.id, + }, + select: { + id: true, + contactId: true, + }, + }); + + contactAttributeKeyCache.revalidate({ + id: contactAttributeKeyId, + environmentId: deletedKey.environmentId, + key: deletedKey.key, + }); + contactAttributeCache.revalidate({ + key: deletedKey.key, + environmentId: deletedKey.environmentId, + }); + + contactCache.revalidate({ + environmentId: deletedKey.environmentId, + }); + + associatedContactAttributes.forEach((contactAttribute) => { + contactAttributeCache.revalidate({ + contactId: contactAttribute.contactId, + }); + contactCache.revalidate({ + id: contactAttribute.contactId, + }); + }); + + return ok(deletedKey); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError) { + if ( + error.code === PrismaErrorType.RecordDoesNotExist || + error.code === PrismaErrorType.RelatedRecordDoesNotExist + ) { + return err({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + } + return err({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: error.message }], + }); + } +}; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi.ts index e16ce064e6..bd9bd0d3a7 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi.ts @@ -1,4 +1,8 @@ -import { ZContactAttributeKeyInput } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; +import { + ZContactAttributeKeyIdSchema, + ZContactAttributeKeyUpdateSchema, +} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; +import { makePartialSchema } from "@/modules/api/v2/types/openapi-response"; import { z } from "zod"; import { ZodOpenApiOperationObject } from "zod-openapi"; import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys"; @@ -9,7 +13,7 @@ export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { description: "Gets a contact attribute key from the database.", requestParams: { path: z.object({ - contactAttributeKeyId: z.string().cuid2(), + id: ZContactAttributeKeyIdSchema, }), }, tags: ["Management API > Contact Attribute Keys"], @@ -18,29 +22,7 @@ export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { description: "Contact attribute key retrieved successfully.", content: { "application/json": { - schema: ZContactAttributeKey, - }, - }, - }, - }, -}; - -export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { - operationId: "deleteContactAttributeKey", - summary: "Delete a contact attribute key", - description: "Deletes a contact attribute key from the database.", - tags: ["Management API > Contact Attribute Keys"], - requestParams: { - path: z.object({ - contactAttributeId: z.string().cuid2(), - }), - }, - responses: { - "200": { - description: "Contact attribute key deleted successfully.", - content: { - "application/json": { - schema: ZContactAttributeKey, + schema: makePartialSchema(ZContactAttributeKey), }, }, }, @@ -54,7 +36,7 @@ export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { tags: ["Management API > Contact Attribute Keys"], requestParams: { path: z.object({ - contactAttributeKeyId: z.string().cuid2(), + id: ZContactAttributeKeyIdSchema, }), }, requestBody: { @@ -62,7 +44,7 @@ export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { description: "The contact attribute key to update", content: { "application/json": { - schema: ZContactAttributeKeyInput, + schema: ZContactAttributeKeyUpdateSchema, }, }, }, @@ -71,7 +53,29 @@ export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { description: "Contact attribute key updated successfully.", content: { "application/json": { - schema: ZContactAttributeKey, + schema: makePartialSchema(ZContactAttributeKey), + }, + }, + }, + }, +}; + +export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { + operationId: "deleteContactAttributeKey", + summary: "Delete a contact attribute key", + description: "Deletes a contact attribute key from the database.", + tags: ["Management API > Contact Attribute Keys"], + requestParams: { + path: z.object({ + id: ZContactAttributeKeyIdSchema, + }), + }, + responses: { + "200": { + description: "Contact attribute key deleted successfully.", + content: { + "application/json": { + schema: makePartialSchema(ZContactAttributeKey), }, }, }, diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts new file mode 100644 index 0000000000..74c92ba32e --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts @@ -0,0 +1,222 @@ +import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key"; +import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; +import { ContactAttributeKey } from "@prisma/client"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { + deleteContactAttributeKey, + getContactAttributeKey, + updateContactAttributeKey, +} from "../contact-attribute-key"; + +// Mock dependencies +vi.mock("@formbricks/database", () => ({ + prisma: { + contactAttributeKey: { + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findMany: vi.fn(), + }, + contactAttribute: { + findMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/cache/contact-attribute-key", () => ({ + contactAttributeKeyCache: { + tag: { + byId: () => "mockTag", + }, + revalidate: vi.fn(), + }, +})); + +// Mock data +const mockContactAttributeKey: ContactAttributeKey = { + id: "cak123", + key: "email", + name: "Email", + description: "User's email address", + environmentId: "env123", + isUnique: true, + type: "default", + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockUpdateInput: TContactAttributeKeyUpdateSchema = { + key: "email", + name: "Email Address", + description: "User's verified email address", +}; + +const prismaNotFoundError = new PrismaClientKnownRequestError("Mock error message", { + code: PrismaErrorType.RelatedRecordDoesNotExist, + clientVersion: "0.0.1", +}); + +const prismaUniqueConstraintError = new PrismaClientKnownRequestError("Mock error message", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", +}); + +describe("getContactAttributeKey", () => { + test("returns ok if contact attribute key is found", async () => { + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValueOnce(mockContactAttributeKey); + const result = await getContactAttributeKey("cak123"); + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.data).toEqual(mockContactAttributeKey); + } + }); + + test("returns err if contact attribute key not found", async () => { + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValueOnce(null); + const result = await getContactAttributeKey("cak999"); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + }); + + test("returns err on Prisma error", async () => { + vi.mocked(prisma.contactAttributeKey.findUnique).mockRejectedValueOnce(new Error("DB error")); + const result = await getContactAttributeKey("error"); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: "DB error" }], + }); + } + }); +}); + +describe("updateContactAttributeKey", () => { + test("returns ok on successful update", async () => { + const updatedKey = { ...mockContactAttributeKey, ...mockUpdateInput }; + vi.mocked(prisma.contactAttributeKey.update).mockResolvedValueOnce(updatedKey); + + vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([ + { id: "contact1", contactId: "contact1" }, + { id: "contact2", contactId: "contact2" }, + ]); + + const result = await updateContactAttributeKey("cak123", mockUpdateInput); + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.data).toEqual(updatedKey); + } + + expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({ + id: "cak123", + environmentId: mockContactAttributeKey.environmentId, + key: mockUpdateInput.key, + }); + }); + + test("returns not_found if record does not exist", async () => { + vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(prismaNotFoundError); + + const result = await updateContactAttributeKey("cak999", mockUpdateInput); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + }); + + test("returns conflict error if key already exists", async () => { + vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(prismaUniqueConstraintError); + + const result = await updateContactAttributeKey("cak123", mockUpdateInput); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "conflict", + details: [ + { field: "contactAttributeKey", issue: 'Contact attribute key with "email" already exists' }, + ], + }); + } + }); + + test("returns internal_server_error if other error occurs", async () => { + vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(new Error("Unknown error")); + + const result = await updateContactAttributeKey("cak123", mockUpdateInput); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: "Unknown error" }], + }); + } + }); +}); + +describe("deleteContactAttributeKey", () => { + test("returns ok on successful delete", async () => { + vi.mocked(prisma.contactAttributeKey.delete).mockResolvedValueOnce(mockContactAttributeKey); + vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([ + { id: "contact1", contactId: "contact1" }, + { id: "contact2", contactId: "contact2" }, + ]); + const result = await deleteContactAttributeKey("cak123"); + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.data).toEqual(mockContactAttributeKey); + } + + expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({ + id: "cak123", + environmentId: mockContactAttributeKey.environmentId, + key: mockContactAttributeKey.key, + }); + }); + + test("returns not_found if record does not exist", async () => { + vi.mocked(prisma.contactAttributeKey.delete).mockRejectedValueOnce(prismaNotFoundError); + + const result = await deleteContactAttributeKey("cak999"); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + }); + + test("returns internal_server_error on other errors", async () => { + vi.mocked(prisma.contactAttributeKey.delete).mockRejectedValueOnce(new Error("Delete error")); + + const result = await deleteContactAttributeKey("cak123"); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: "Delete error" }], + }); + } + }); +}); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts new file mode 100644 index 0000000000..060682b026 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts @@ -0,0 +1,131 @@ +import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; +import { responses } from "@/modules/api/v2/lib/response"; +import { handleApiError } from "@/modules/api/v2/lib/utils"; +import { + deleteContactAttributeKey, + getContactAttributeKey, + updateContactAttributeKey, +} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key"; +import { + ZContactAttributeKeyIdSchema, + ZContactAttributeKeyUpdateSchema, +} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { NextRequest } from "next/server"; +import { z } from "zod"; + +export const GET = async ( + request: NextRequest, + props: { params: Promise<{ contactAttributeKeyId: string }> } +) => + authenticatedApiClient({ + request, + schemas: { + params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }), + }, + externalParams: props.params, + handler: async ({ authentication, parsedInput }) => { + const { params } = parsedInput; + + const res = await getContactAttributeKey(params.contactAttributeKeyId); + + if (!res.ok) { + return handleApiError(request, res.error); + } + + if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "GET")) { + return handleApiError(request, { + type: "unauthorized", + details: [{ field: "environment", issue: "unauthorized" }], + }); + } + + return responses.successResponse(res); + }, + }); + +export const PUT = async ( + request: NextRequest, + props: { params: Promise<{ contactAttributeKeyId: string }> } +) => + authenticatedApiClient({ + request, + schemas: { + params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }), + body: ZContactAttributeKeyUpdateSchema, + }, + externalParams: props.params, + handler: async ({ authentication, parsedInput }) => { + const { params, body } = parsedInput; + + const res = await getContactAttributeKey(params.contactAttributeKeyId); + + if (!res.ok) { + return handleApiError(request, res.error); + } + if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "PUT")) { + return handleApiError(request, { + type: "unauthorized", + details: [{ field: "environment", issue: "unauthorized" }], + }); + } + + if (res.data.isUnique) { + return handleApiError(request, { + type: "bad_request", + details: [{ field: "contactAttributeKey", issue: "cannot update unique contact attribute key" }], + }); + } + + const updatedContactAttributeKey = await updateContactAttributeKey(params.contactAttributeKeyId, body); + + if (!updatedContactAttributeKey.ok) { + return handleApiError(request, updatedContactAttributeKey.error); + } + + return responses.successResponse(updatedContactAttributeKey); + }, + }); + +export const DELETE = async ( + request: NextRequest, + props: { params: Promise<{ contactAttributeKeyId: string }> } +) => + authenticatedApiClient({ + request, + schemas: { + params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }), + }, + externalParams: props.params, + handler: async ({ authentication, parsedInput }) => { + const { params } = parsedInput; + + const res = await getContactAttributeKey(params.contactAttributeKeyId); + + if (!res.ok) { + return handleApiError(request, res.error); + } + + if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "DELETE")) { + return handleApiError(request, { + type: "unauthorized", + details: [{ field: "environment", issue: "unauthorized" }], + }); + } + + if (res.data.isUnique) { + return handleApiError(request, { + type: "bad_request", + details: [{ field: "contactAttributeKey", issue: "cannot delete unique contact attribute key" }], + }); + } + + const deletedContactAttributeKey = await deleteContactAttributeKey(params.contactAttributeKeyId); + + if (!deletedContactAttributeKey.ok) { + return handleApiError(request, deletedContactAttributeKey.error); + } + + return responses.successResponse(deletedContactAttributeKey); + }, + }); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts new file mode 100644 index 0000000000..b855994b92 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { extendZodWithOpenApi } from "zod-openapi"; +import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys"; + +extendZodWithOpenApi(z); + +export const ZContactAttributeKeyIdSchema = z + .string() + .cuid2() + .openapi({ + ref: "contactAttributeKeyId", + description: "The ID of the contact attribute key", + param: { + name: "id", + in: "path", + }, + }); + +export const ZContactAttributeKeyUpdateSchema = ZContactAttributeKey.pick({ + name: true, + description: true, + key: true, +}).openapi({ + ref: "contactAttributeKeyUpdate", + description: "A contact attribute key to update.", +}); + +export type TContactAttributeKeyUpdateSchema = z.infer; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts new file mode 100644 index 0000000000..d89c88e21c --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts @@ -0,0 +1,105 @@ +import { cache } from "@/lib/cache"; +import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key"; +import { getContactAttributeKeysQuery } from "@/modules/api/v2/management/contact-attribute-keys/lib/utils"; +import { + TContactAttributeKeyInput, + TGetContactAttributeKeysFilter, +} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success"; +import { ContactAttributeKey, Prisma } from "@prisma/client"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { Result, err, ok } from "@formbricks/types/error-handlers"; + +export const getContactAttributeKeys = reactCache( + async (environmentIds: string[], params: TGetContactAttributeKeysFilter) => + cache( + async (): Promise, ApiErrorResponseV2>> => { + try { + const query = getContactAttributeKeysQuery(environmentIds, params); + + const [keys, count] = await prisma.$transaction([ + prisma.contactAttributeKey.findMany({ + ...query, + }), + prisma.contactAttributeKey.count({ + where: query.where, + }), + ]); + + return ok({ data: keys, meta: { total: count, limit: params.limit, offset: params.skip } }); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "contactAttributeKeys", issue: error.message }], + }); + } + }, + [`management-getContactAttributeKeys-${environmentIds.join(",")}-${JSON.stringify(params)}`], + { + tags: environmentIds.map((environmentId) => + contactAttributeKeyCache.tag.byEnvironmentId(environmentId) + ), + } + )() +); + +export const createContactAttributeKey = async ( + contactAttributeKey: TContactAttributeKeyInput +): Promise> => { + const { environmentId, name, description, key } = contactAttributeKey; + + try { + const prismaData: Prisma.ContactAttributeKeyCreateInput = { + environment: { + connect: { + id: environmentId, + }, + }, + name, + description, + key, + }; + + const createdContactAttributeKey = await prisma.contactAttributeKey.create({ + data: prismaData, + }); + + contactAttributeKeyCache.revalidate({ + environmentId: createdContactAttributeKey.environmentId, + key: createdContactAttributeKey.key, + }); + + return ok(createdContactAttributeKey); + } catch (error) { + if (error instanceof PrismaClientKnownRequestError) { + if ( + error.code === PrismaErrorType.RecordDoesNotExist || + error.code === PrismaErrorType.RelatedRecordDoesNotExist + ) { + return err({ + type: "not_found", + details: [{ field: "contactAttributeKey", issue: "not found" }], + }); + } + if (error.code === PrismaErrorType.UniqueConstraintViolation) { + return err({ + type: "conflict", + details: [ + { + field: "contactAttributeKey", + issue: `Contact attribute key with "${contactAttributeKey.key}" already exists`, + }, + ], + }); + } + } + return err({ + type: "internal_server_error", + details: [{ field: "contactAttributeKey", issue: error.message }], + }); + } +}; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/openapi.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/openapi.ts index d33cdd8dd4..c8d2094059 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/openapi.ts @@ -8,9 +8,9 @@ import { ZGetContactAttributeKeysFilter, } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; import { managementServer } from "@/modules/api/v2/management/lib/openapi"; -import { z } from "zod"; +import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response"; import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi"; -import { ZContactAttributeKey } from "@formbricks/types/contact-attribute-key"; +import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys"; export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = { operationId: "getContactAttributeKeys", @@ -18,14 +18,14 @@ export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = { description: "Gets contact attribute keys from the database.", tags: ["Management API > Contact Attribute Keys"], requestParams: { - query: ZGetContactAttributeKeysFilter, + query: ZGetContactAttributeKeysFilter.sourceType(), }, responses: { "200": { description: "Contact attribute keys retrieved successfully.", content: { "application/json": { - schema: z.array(ZContactAttributeKey), + schema: responseWithMetaSchema(makePartialSchema(ZContactAttributeKey)), }, }, }, @@ -49,6 +49,11 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = { responses: { "201": { description: "Contact attribute key created successfully.", + content: { + "application/json": { + schema: makePartialSchema(ZContactAttributeKey), + }, + }, }, }, }; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts new file mode 100644 index 0000000000..9345ed3d32 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/contact-attribute-key.test.ts @@ -0,0 +1,166 @@ +import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key"; +import { + TContactAttributeKeyInput, + TGetContactAttributeKeysFilter, +} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; +import { ContactAttributeKey } from "@prisma/client"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { createContactAttributeKey, getContactAttributeKeys } from "../contact-attribute-key"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + $transaction: vi.fn(), + contactAttributeKey: { + findMany: vi.fn(), + count: vi.fn(), + create: vi.fn(), + }, + }, +})); +vi.mock("@/lib/cache/contact-attribute-key", () => ({ + contactAttributeKeyCache: { + revalidate: vi.fn(), + tag: { + byEnvironmentId: vi.fn(), + }, + }, +})); + +describe("getContactAttributeKeys", () => { + const environmentIds = ["env1", "env2"]; + const params: TGetContactAttributeKeysFilter = { + limit: 10, + skip: 0, + order: "asc", + sortBy: "createdAt", + }; + const fakeContactAttributeKeys = [ + { id: "key1", environmentId: "env1", name: "Key One", key: "keyOne" }, + { id: "key2", environmentId: "env1", name: "Key Two", key: "keyTwo" }, + ]; + const count = fakeContactAttributeKeys.length; + + test("returns ok response with contact attribute keys and meta", async () => { + vi.mocked(prisma.$transaction).mockResolvedValueOnce([fakeContactAttributeKeys, count]); + + const result = await getContactAttributeKeys(environmentIds, params); + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.data.data).toEqual(fakeContactAttributeKeys); + expect(result.data.meta).toEqual({ + total: count, + limit: params.limit, + offset: params.skip, + }); + } + }); + + test("returns error when prisma.$transaction throws", async () => { + vi.mocked(prisma.$transaction).mockRejectedValueOnce(new Error("Test error")); + + const result = await getContactAttributeKeys(environmentIds, params); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error?.type).toEqual("internal_server_error"); + } + }); +}); + +describe("createContactAttributeKey", () => { + const inputContactAttributeKey: TContactAttributeKeyInput = { + environmentId: "env1", + name: "New Contact Attribute Key", + key: "newKey", + description: "Description for new key", + }; + + const createdContactAttributeKey: ContactAttributeKey = { + id: "key100", + environmentId: inputContactAttributeKey.environmentId, + name: inputContactAttributeKey.name, + key: inputContactAttributeKey.key, + description: inputContactAttributeKey.description, + isUnique: false, + type: "custom", + createdAt: new Date(), + updatedAt: new Date(), + }; + + test("creates a contact attribute key and revalidates cache", async () => { + vi.mocked(prisma.contactAttributeKey.create).mockResolvedValueOnce(createdContactAttributeKey); + + const result = await createContactAttributeKey(inputContactAttributeKey); + expect(prisma.contactAttributeKey.create).toHaveBeenCalled(); + expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({ + environmentId: createdContactAttributeKey.environmentId, + key: createdContactAttributeKey.key, + }); + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.data).toEqual(createdContactAttributeKey); + } + }); + + test("returns error when creation fails", async () => { + vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(new Error("Creation failed")); + + const result = await createContactAttributeKey(inputContactAttributeKey); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error.type).toEqual("internal_server_error"); + } + }); + + test("returns conflict error when key already exists", async () => { + const errToThrow = new PrismaClientKnownRequestError("Mock error message", { + code: PrismaErrorType.UniqueConstraintViolation, + clientVersion: "0.0.1", + }); + vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(errToThrow); + + const result = await createContactAttributeKey(inputContactAttributeKey); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "conflict", + details: [ + { + field: "contactAttributeKey", + issue: 'Contact attribute key with "newKey" already exists', + }, + ], + }); + } + }); + + test("returns not found error when related record does not exist", async () => { + const errToThrow = new PrismaClientKnownRequestError("Mock error message", { + code: PrismaErrorType.RelatedRecordDoesNotExist, + clientVersion: "0.0.1", + }); + vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(errToThrow); + + const result = await createContactAttributeKey(inputContactAttributeKey); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error).toStrictEqual({ + type: "not_found", + details: [ + { + field: "contactAttributeKey", + issue: "not found", + }, + ], + }); + } + }); +}); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/utils.test.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/utils.test.ts new file mode 100644 index 0000000000..4146b1f677 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/tests/utils.test.ts @@ -0,0 +1,106 @@ +import { TGetContactAttributeKeysFilter } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getContactAttributeKeysQuery } from "../utils"; + +describe("getContactAttributeKeysQuery", () => { + const environmentId = "env-123"; + const baseParams: TGetContactAttributeKeysFilter = { + limit: 10, + skip: 0, + order: "asc", + sortBy: "createdAt", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns query with environmentId in array when no params are provided", () => { + const environmentIds = ["env-1", "env-2"]; + const result = getContactAttributeKeysQuery(environmentIds); + + expect(result).toEqual({ + where: { + environmentId: { + in: environmentIds, + }, + }, + }); + }); + + test("applies common filters when provided", () => { + const environmentIds = ["env-1", "env-2"]; + const params: TGetContactAttributeKeysFilter = { + ...baseParams, + environmentId, + }; + const result = getContactAttributeKeysQuery(environmentIds, params); + + expect(result).toEqual({ + where: { + environmentId: { + in: environmentIds, + }, + }, + take: 10, + orderBy: { + createdAt: "asc", + }, + }); + }); + + test("applies date filters when provided", () => { + const environmentIds = ["env-1", "env-2"]; + const startDate = new Date("2023-01-01"); + const endDate = new Date("2023-12-31"); + + const params: TGetContactAttributeKeysFilter = { + ...baseParams, + environmentId, + startDate, + endDate, + }; + const result = getContactAttributeKeysQuery(environmentIds, params); + + expect(result).toEqual({ + where: { + environmentId: { + in: environmentIds, + }, + createdAt: { + gte: startDate, + lte: endDate, + }, + }, + take: 10, + orderBy: { + createdAt: "asc", + }, + }); + }); + + test("handles multiple filter parameters correctly", () => { + const environmentIds = ["env-1", "env-2"]; + const params: TGetContactAttributeKeysFilter = { + environmentId, + limit: 5, + skip: 10, + sortBy: "updatedAt", + order: "asc", + }; + const result = getContactAttributeKeysQuery(environmentIds, params); + + expect(result).toEqual({ + where: { + environmentId: { + in: environmentIds, + }, + }, + take: 5, + skip: 10, + orderBy: { + updatedAt: "asc", + }, + }); + }); +}); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/lib/utils.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/utils.ts new file mode 100644 index 0000000000..5d4e1881c4 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/lib/utils.ts @@ -0,0 +1,26 @@ +import { TGetContactAttributeKeysFilter } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; +import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils"; +import { Prisma } from "@prisma/client"; + +export const getContactAttributeKeysQuery = ( + environmentIds: string[], + params?: TGetContactAttributeKeysFilter +): Prisma.ContactAttributeKeyFindManyArgs => { + let query: Prisma.ContactAttributeKeyFindManyArgs = { + where: { + environmentId: { + in: environmentIds, + }, + }, + }; + + if (!params) return query; + + const baseFilter = pickCommonFilter(params); + + if (baseFilter) { + query = buildCommonFilterQuery(query, baseFilter); + } + + return query; +}; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts new file mode 100644 index 0000000000..eb97fa01d4 --- /dev/null +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts @@ -0,0 +1,73 @@ +import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; +import { responses } from "@/modules/api/v2/lib/response"; +import { handleApiError } from "@/modules/api/v2/lib/utils"; +import { + createContactAttributeKey, + getContactAttributeKeys, +} from "@/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key"; +import { + ZContactAttributeKeyInput, + ZGetContactAttributeKeysFilter, +} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; +import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { NextRequest } from "next/server"; + +export const GET = async (request: NextRequest) => + authenticatedApiClient({ + request, + schemas: { + query: ZGetContactAttributeKeysFilter.sourceType(), + }, + handler: async ({ authentication, parsedInput }) => { + const { query } = parsedInput; + + let environmentIds: string[] = []; + + if (query.environmentId) { + if (!hasPermission(authentication.environmentPermissions, query.environmentId, "GET")) { + return handleApiError(request, { + type: "unauthorized", + }); + } + environmentIds = [query.environmentId]; + } else { + environmentIds = authentication.environmentPermissions.map((permission) => permission.environmentId); + } + + const res = await getContactAttributeKeys(environmentIds, query); + + if (!res.ok) { + return handleApiError(request, res.error); + } + + return responses.successResponse(res.data); + }, + }); + +export const POST = async (request: NextRequest) => + authenticatedApiClient({ + request, + schemas: { + body: ZContactAttributeKeyInput, + }, + handler: async ({ authentication, parsedInput }) => { + const { body } = parsedInput; + + if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) { + return handleApiError(request, { + type: "forbidden", + details: [ + { field: "environmentId", issue: "does not have permission to create contact attribute key" }, + ], + }); + } + + const createContactAttributeKeyResult = await createContactAttributeKey(body); + + if (!createContactAttributeKeyResult.ok) { + return handleApiError(request, createContactAttributeKeyResult.error); + } + + return responses.createdResponse(createContactAttributeKeyResult); + }, + }); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts index 24e02f2ee4..386d966c53 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts @@ -1,18 +1,13 @@ +import { ZGetFilter } from "@/modules/api/v2/types/api-filter"; import { z } from "zod"; import { extendZodWithOpenApi } from "zod-openapi"; import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys"; extendZodWithOpenApi(z); -export const ZGetContactAttributeKeysFilter = 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 const ZGetContactAttributeKeysFilter = ZGetFilter.extend({ + environmentId: z.string().cuid2().optional().describe("The environment ID to filter by"), +}) .refine( (data) => { if (data.startDate && data.endDate && data.startDate > data.endDate) { @@ -23,13 +18,15 @@ export const ZGetContactAttributeKeysFilter = z { message: "startDate must be before endDate", } - ); + ) + .describe("Filter for retrieving contact attribute keys"); + +export type TGetContactAttributeKeysFilter = z.infer; export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({ key: true, name: true, description: true, - type: true, environmentId: true, }).openapi({ ref: "contactAttributeKeyInput", diff --git a/apps/web/modules/api/v2/management/lib/utils.ts b/apps/web/modules/api/v2/management/lib/utils.ts index 105cda6122..36d46ce1a1 100644 --- a/apps/web/modules/api/v2/management/lib/utils.ts +++ b/apps/web/modules/api/v2/management/lib/utils.ts @@ -14,7 +14,8 @@ type HasFindMany = | Prisma.ResponseFindManyArgs | Prisma.TeamFindManyArgs | Prisma.ProjectTeamFindManyArgs - | Prisma.UserFindManyArgs; + | Prisma.UserFindManyArgs + | Prisma.ContactAttributeKeyFindManyArgs; export function buildCommonFilterQuery(query: T, params: TGetFilter): T { const { limit, skip, sortBy, order, startDate, endDate } = params || {}; diff --git a/apps/web/modules/api/v2/management/responses/route.ts b/apps/web/modules/api/v2/management/responses/route.ts index 43961806ec..9a5833930f 100644 --- a/apps/web/modules/api/v2/management/responses/route.ts +++ b/apps/web/modules/api/v2/management/responses/route.ts @@ -81,6 +81,6 @@ export const POST = async (request: Request) => return handleApiError(request, createResponseResult.error); } - return responses.successResponse({ data: createResponseResult.data }); + return responses.createdResponse({ data: createResponseResult.data }); }, }); diff --git a/apps/web/modules/api/v2/management/webhooks/route.ts b/apps/web/modules/api/v2/management/webhooks/route.ts index b18ed34a80..e34d3ef105 100644 --- a/apps/web/modules/api/v2/management/webhooks/route.ts +++ b/apps/web/modules/api/v2/management/webhooks/route.ts @@ -72,6 +72,6 @@ export const POST = async (request: NextRequest) => return handleApiError(request, createWebhookResult.error); } - return responses.successResponse(createWebhookResult); + return responses.createdResponse(createWebhookResult); }, }); diff --git a/apps/web/modules/api/v2/openapi-document.ts b/apps/web/modules/api/v2/openapi-document.ts index d099f912c8..dd9a34bfbc 100644 --- a/apps/web/modules/api/v2/openapi-document.ts +++ b/apps/web/modules/api/v2/openapi-document.ts @@ -1,4 +1,4 @@ -// import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi"; +import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi"; // import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi"; // import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi"; import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi"; @@ -42,7 +42,7 @@ const document = createDocument({ ...bulkContactPaths, // ...contactPaths, // ...contactAttributePaths, - // ...contactAttributeKeyPaths, + ...contactAttributeKeyPaths, ...surveyPaths, ...surveyContactLinksBySegmentPaths, ...webhookPaths, diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts index 44b61a41bf..14f47636ee 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts @@ -59,6 +59,6 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza return handleApiError(request, createTeamResult.error); } - return responses.successResponse({ data: createTeamResult.data }); + return responses.createdResponse({ data: createTeamResult.data }); }, }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts index 11495d89b0..30f22e9bdc 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts @@ -79,7 +79,7 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza return handleApiError(request, createUserResult.error); } - return responses.successResponse({ data: createUserResult.data }); + return responses.createdResponse({ data: createUserResult.data }); }, }); diff --git a/docs/api-v2-reference/openapi.yml b/docs/api-v2-reference/openapi.yml index 4da48a310d..892aea677e 100644 --- a/docs/api-v2-reference/openapi.yml +++ b/docs/api-v2-reference/openapi.yml @@ -1627,6 +1627,386 @@ paths: - skippedContacts required: - data + /contact-attribute-keys: + servers: *a6 + get: + operationId: getContactAttributeKeys + summary: Get contact attribute keys + description: Gets contact attribute keys from the database. + tags: + - Management API > Contact Attribute Keys + parameters: + - in: query + name: limit + description: Number of items to return + schema: + type: number + minimum: 1 + maximum: 250 + default: 50 + description: Number of items to return + - in: query + name: skip + description: Number of items to skip + schema: + type: number + minimum: 0 + default: 0 + description: Number of items to skip + - in: query + name: sortBy + description: Sort by field + schema: + type: string + enum: *a7 + default: createdAt + description: Sort by field + - in: query + name: order + description: Sort order + schema: + type: string + enum: *a8 + default: desc + description: Sort order + - in: query + name: startDate + description: Start date + schema: + type: string + description: Start date + - in: query + name: endDate + description: End date + schema: + type: string + description: End date + - in: query + name: environmentId + description: The environment ID to filter by + schema: + type: string + description: The environment ID to filter by + responses: + "200": + description: Contact attribute keys retrieved successfully. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: string + description: The ID of the contact attribute key + createdAt: + type: string + description: The date and time the contact attribute key was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the contact attribute key was last updated + example: 2021-01-01T00:00:00.000Z + isUnique: + type: boolean + description: Whether the attribute must have unique values across contacts + example: false + key: + type: string + description: The attribute identifier used in the system + example: email + name: + type: + - string + - "null" + description: Display name for the attribute + example: Email Address + description: + type: + - string + - "null" + description: Description of the attribute + example: The user's email address + type: + type: string + enum: + - default + - custom + description: Whether this is a default or custom attribute + example: custom + environmentId: + type: string + description: The ID of the environment this attribute belongs to + meta: + type: object + properties: + total: + type: number + limit: + type: number + offset: + type: number + post: + operationId: createContactAttributeKey + summary: Create a contact attribute key + description: Creates a contact attribute key in the database. + tags: + - Management API > Contact Attribute Keys + requestBody: + required: true + description: The contact attribute key to create + content: + application/json: + schema: + $ref: "#/components/schemas/contactAttributeKeyInput" + responses: + "201": + description: Contact attribute key created successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the contact attribute key + createdAt: + type: string + description: The date and time the contact attribute key was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the contact attribute key was last updated + example: 2021-01-01T00:00:00.000Z + isUnique: + type: boolean + description: Whether the attribute must have unique values across contacts + example: false + key: + type: string + description: The attribute identifier used in the system + example: email + name: + type: + - string + - "null" + description: Display name for the attribute + example: Email Address + description: + type: + - string + - "null" + description: Description of the attribute + example: The user's email address + type: + type: string + enum: + - default + - custom + description: Whether this is a default or custom attribute + example: custom + environmentId: + type: string + description: The ID of the environment this attribute belongs to + /contact-attribute-keys/{id}: + servers: *a6 + get: + operationId: getContactAttributeKey + summary: Get a contact attribute key + description: Gets a contact attribute key from the database. + tags: + - Management API > Contact Attribute Keys + parameters: + - in: path + name: id + description: The ID of the contact attribute key + schema: + $ref: "#/components/schemas/contactAttributeKeyId" + required: true + responses: + "200": + description: Contact attribute key retrieved successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the contact attribute key + createdAt: + type: string + description: The date and time the contact attribute key was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the contact attribute key was last updated + example: 2021-01-01T00:00:00.000Z + isUnique: + type: boolean + description: Whether the attribute must have unique values across contacts + example: false + key: + type: string + description: The attribute identifier used in the system + example: email + name: + type: + - string + - "null" + description: Display name for the attribute + example: Email Address + description: + type: + - string + - "null" + description: Description of the attribute + example: The user's email address + type: + type: string + enum: + - default + - custom + description: Whether this is a default or custom attribute + example: custom + environmentId: + type: string + description: The ID of the environment this attribute belongs to + put: + operationId: updateContactAttributeKey + summary: Update a contact attribute key + description: Updates a contact attribute key in the database. + tags: + - Management API > Contact Attribute Keys + parameters: + - in: path + name: id + description: The ID of the contact attribute key + schema: + $ref: "#/components/schemas/contactAttributeKeyId" + required: true + requestBody: + required: true + description: The contact attribute key to update + content: + application/json: + schema: + $ref: "#/components/schemas/contactAttributeKeyUpdate" + responses: + "200": + description: Contact attribute key updated successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the contact attribute key + createdAt: + type: string + description: The date and time the contact attribute key was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the contact attribute key was last updated + example: 2021-01-01T00:00:00.000Z + isUnique: + type: boolean + description: Whether the attribute must have unique values across contacts + example: false + key: + type: string + description: The attribute identifier used in the system + example: email + name: + type: + - string + - "null" + description: Display name for the attribute + example: Email Address + description: + type: + - string + - "null" + description: Description of the attribute + example: The user's email address + type: + type: string + enum: + - default + - custom + description: Whether this is a default or custom attribute + example: custom + environmentId: + type: string + description: The ID of the environment this attribute belongs to + delete: + operationId: deleteContactAttributeKey + summary: Delete a contact attribute key + description: Deletes a contact attribute key from the database. + tags: + - Management API > Contact Attribute Keys + parameters: + - in: path + name: id + description: The ID of the contact attribute key + schema: + $ref: "#/components/schemas/contactAttributeKeyId" + required: true + responses: + "200": + description: Contact attribute key deleted successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the contact attribute key + createdAt: + type: string + description: The date and time the contact attribute key was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the contact attribute key was last updated + example: 2021-01-01T00:00:00.000Z + isUnique: + type: boolean + description: Whether the attribute must have unique values across contacts + example: false + key: + type: string + description: The attribute identifier used in the system + example: email + name: + type: + - string + - "null" + description: Display name for the attribute + example: Email Address + description: + type: + - string + - "null" + description: Description of the attribute + example: The user's email address + type: + type: string + enum: + - default + - custom + description: Whether this is a default or custom attribute + example: custom + environmentId: + type: string + description: The ID of the environment this attribute belongs to /surveys/{surveyId}/contact-links/contacts/{contactId}/: servers: *a6 get: @@ -4218,6 +4598,61 @@ components: responseId: type: string description: The ID of the response + contactAttributeKeyInput: + type: object + properties: + key: + type: string + description: The attribute identifier used in the system + example: email + name: + type: + - string + - "null" + description: Display name for the attribute + example: Email Address + description: + type: + - string + - "null" + description: Description of the attribute + example: The user's email address + environmentId: + type: string + description: The ID of the environment this attribute belongs to + required: + - key + - name + - description + - environmentId + description: Input data for creating or updating a contact attribute + contactAttributeKeyId: + type: string + description: The ID of the contact attribute key + contactAttributeKeyUpdate: + type: object + properties: + name: + type: + - string + - "null" + description: Display name for the attribute + example: Email Address + description: + type: + - string + - "null" + description: Description of the attribute + example: The user's email address + key: + type: string + description: The attribute identifier used in the system + example: email + required: + - name + - description + - key + description: A contact attribute key to update. webhookId: type: string description: The ID of the webhook