feat: Implement v2 management api endpoint for contact attribute keys (#5316)

Co-authored-by: Victor Santos <victor@formbricks.com>
This commit is contained in:
Piyush Gupta
2025-04-23 21:18:18 +05:30
committed by GitHub
parent 36943bb786
commit 630e5489ec
24 changed files with 1619 additions and 56 deletions

View File

@@ -0,0 +1,7 @@
import {
DELETE,
GET,
PUT,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route";
export { GET, PUT, DELETE };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -72,6 +72,6 @@ export const POST = async (request: NextRequest) =>
return handleApiError(request, createWebhookResult.error);
}
return responses.successResponse(createWebhookResult);
return responses.createdResponse(createWebhookResult);
},
});

View File

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

View File

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

View File

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

View File

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