mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-05 16:19:55 -06:00
feat: Implement v2 management api endpoint for contact attribute keys (#5316)
Co-authored-by: Victor Santos <victor@formbricks.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import {
|
||||
DELETE,
|
||||
GET,
|
||||
PUT,
|
||||
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route";
|
||||
|
||||
export { GET, PUT, DELETE };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET, POST } from "@/modules/api/v2/management/contact-attribute-keys/route";
|
||||
|
||||
export { GET, POST };
|
||||
@@ -21,11 +21,19 @@ export type ExtendedSchemas = {
|
||||
};
|
||||
|
||||
// Define a type that returns separate keys for each input type.
|
||||
export type ParsedSchemas<S extends ExtendedSchemas | undefined> = {
|
||||
body?: S extends { body: z.ZodObject<any> } ? z.infer<S["body"]> : undefined;
|
||||
query?: S extends { query: z.ZodObject<any> } ? z.infer<S["query"]> : undefined;
|
||||
params?: S extends { params: z.ZodObject<any> } ? z.infer<S["params"]> : 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 ExtendedSchemas | undefined> = S extends object
|
||||
? {
|
||||
[K in keyof S as NonNullable<S[K]> extends z.ZodObject<any> ? K : never]: NonNullable<
|
||||
S[K]
|
||||
> extends z.ZodObject<any>
|
||||
? z.infer<NonNullable<S[K]>>
|
||||
: never;
|
||||
}
|
||||
: {};
|
||||
|
||||
export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
request,
|
||||
|
||||
@@ -260,6 +260,34 @@ const successResponse = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const createdResponse = ({
|
||||
data,
|
||||
meta,
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
data: Object;
|
||||
meta?: Record<string, unknown>;
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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("*");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
|
||||
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<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
|
||||
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<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
|
||||
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 }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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<typeof ZContactAttributeKeyUpdateSchema>;
|
||||
@@ -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<Result<ApiResponseWithMeta<ContactAttributeKey[]>, 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<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
|
||||
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 }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Prisma.ContactAttributeKeyFindManyArgs>(query, baseFilter);
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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<typeof ZGetContactAttributeKeysFilter>;
|
||||
|
||||
export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
|
||||
key: true,
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
}).openapi({
|
||||
ref: "contactAttributeKeyInput",
|
||||
|
||||
@@ -14,7 +14,8 @@ type HasFindMany =
|
||||
| Prisma.ResponseFindManyArgs
|
||||
| Prisma.TeamFindManyArgs
|
||||
| Prisma.ProjectTeamFindManyArgs
|
||||
| Prisma.UserFindManyArgs;
|
||||
| Prisma.UserFindManyArgs
|
||||
| Prisma.ContactAttributeKeyFindManyArgs;
|
||||
|
||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -72,6 +72,6 @@ export const POST = async (request: NextRequest) =>
|
||||
return handleApiError(request, createWebhookResult.error);
|
||||
}
|
||||
|
||||
return responses.successResponse(createWebhookResult);
|
||||
return responses.createdResponse(createWebhookResult);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user