feat: add single contact using the API V2 (#6168)

This commit is contained in:
Victor Hugo dos Santos
2025-07-10 17:34:18 +07:00
committed by GitHub
parent 492a59e7de
commit 4e52556f7e
23 changed files with 1782 additions and 451 deletions

View File

@@ -0,0 +1 @@
export { POST } from "@/modules/ee/contacts/api/v2/management/contacts/route";

View File

@@ -1,79 +0,0 @@
import { ZContactAttributeInput } from "@/modules/api/v2/management/contact-attributes/types/contact-attributes";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
export const getContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttribute",
summary: "Get a contact attribute",
description: "Gets a contact attribute from the database.",
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
tags: ["Management API - Contact Attributes"],
responses: {
"200": {
description: "Contact retrieved successfully.",
content: {
"application/json": {
schema: ZContactAttribute,
},
},
},
},
};
export const deleteContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttribute",
summary: "Delete a contact attribute",
description: "Deletes a contact attribute from the database.",
tags: ["Management API - Contact Attributes"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
responses: {
"200": {
description: "Contact deleted successfully.",
content: {
"application/json": {
schema: ZContactAttribute,
},
},
},
},
};
export const updateContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContactAttribute",
summary: "Update a contact attribute",
description: "Updates a contact attribute in the database.",
tags: ["Management API - Contact Attributes"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
requestBody: {
required: true,
description: "The response to update",
content: {
"application/json": {
schema: ZContactAttributeInput,
},
},
},
responses: {
"200": {
description: "Response updated successfully.",
content: {
"application/json": {
schema: ZContactAttribute,
},
},
},
},
};

View File

@@ -1,68 +0,0 @@
import {
deleteContactAttributeEndpoint,
getContactAttributeEndpoint,
updateContactAttributeEndpoint,
} from "@/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi";
import {
ZContactAttributeInput,
ZGetContactAttributesFilter,
} from "@/modules/api/v2/management/contact-attributes/types/contact-attributes";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContactAttribute } from "@formbricks/types/contact-attribute";
export const getContactAttributesEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributes",
summary: "Get contact attributes",
description: "Gets contact attributes from the database.",
tags: ["Management API - Contact Attributes"],
requestParams: {
query: ZGetContactAttributesFilter,
},
responses: {
"200": {
description: "Contact attributes retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZContactAttribute),
},
},
},
},
};
export const createContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "createContactAttribute",
summary: "Create a contact attribute",
description: "Creates a contact attribute in the database.",
tags: ["Management API - Contact Attributes"],
requestBody: {
required: true,
description: "The contact attribute to create",
content: {
"application/json": {
schema: ZContactAttributeInput,
},
},
},
responses: {
"201": {
description: "Contact attribute created successfully.",
},
},
};
export const contactAttributePaths: ZodOpenApiPathsObject = {
"/contact-attributes": {
servers: managementServer,
get: getContactAttributesEndpoint,
post: createContactAttributeEndpoint,
},
"/contact-attributes/{id}": {
servers: managementServer,
get: getContactAttributeEndpoint,
put: updateContactAttributeEndpoint,
delete: deleteContactAttributeEndpoint,
},
};

View File

@@ -1,34 +0,0 @@
import { z } from "zod";
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
export const ZGetContactAttributesFilter = 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(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export const ZContactAttributeInput = ZContactAttribute.pick({
attributeKeyId: true,
contactId: true,
value: true,
}).openapi({
ref: "contactAttributeInput",
description: "Input data for creating or updating a contact attribute",
});
export type TContactAttributeInput = z.infer<typeof ZContactAttributeInput>;

View File

@@ -1,79 +0,0 @@
import { ZContactInput } from "@/modules/api/v2/management/contacts/types/contacts";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
export const getContactEndpoint: ZodOpenApiOperationObject = {
operationId: "getContact",
summary: "Get a contact",
description: "Gets a contact from the database.",
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
}),
},
tags: ["Management API - Contacts"],
responses: {
"200": {
description: "Contact retrieved successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};
export const deleteContactEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContact",
summary: "Delete a contact",
description: "Deletes a contact from the database.",
tags: ["Management API - Contacts"],
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
}),
},
responses: {
"200": {
description: "Contact deleted successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};
export const updateContactEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContact",
summary: "Update a contact",
description: "Updates a contact in the database.",
tags: ["Management API - Contacts"],
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
}),
},
requestBody: {
required: true,
description: "The response to update",
content: {
"application/json": {
schema: ZContactInput,
},
},
},
responses: {
"200": {
description: "Response updated successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};

View File

@@ -1,70 +0,0 @@
import {
deleteContactEndpoint,
getContactEndpoint,
updateContactEndpoint,
} from "@/modules/api/v2/management/contacts/[contactId]/lib/openapi";
import { ZContactInput, ZGetContactsFilter } from "@/modules/api/v2/management/contacts/types/contacts";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
export const getContactsEndpoint: ZodOpenApiOperationObject = {
operationId: "getContacts",
summary: "Get contacts",
description: "Gets contacts from the database.",
requestParams: {
query: ZGetContactsFilter,
},
tags: ["Management API - Contacts"],
responses: {
"200": {
description: "Contacts retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZContact),
},
},
},
},
};
export const createContactEndpoint: ZodOpenApiOperationObject = {
operationId: "createContact",
summary: "Create a contact",
description: "Creates a contact in the database.",
tags: ["Management API - Contacts"],
requestBody: {
required: true,
description: "The contact to create",
content: {
"application/json": {
schema: ZContactInput,
},
},
},
responses: {
"201": {
description: "Contact created successfully.",
content: {
"application/json": {
schema: ZContact,
},
},
},
},
};
export const contactPaths: ZodOpenApiPathsObject = {
"/contacts": {
servers: managementServer,
get: getContactsEndpoint,
post: createContactEndpoint,
},
"/contacts/{id}": {
servers: managementServer,
get: getContactEndpoint,
put: updateContactEndpoint,
delete: deleteContactEndpoint,
},
};

View File

@@ -1,40 +0,0 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
extendZodWithOpenApi(z);
export const ZGetContactsFilter = 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(),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export const ZContactInput = ZContact.pick({
userId: true,
environmentId: true,
})
.partial({
userId: true,
})
.openapi({
ref: "contactCreate",
description: "A contact to create",
});
export type TContactInput = z.infer<typeof ZContactInput>;

View File

@@ -1,6 +1,4 @@
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";
import { surveyContactLinksBySegmentPaths } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/lib/openapi";
import { surveyPaths } from "@/modules/api/v2/management/surveys/lib/openapi";
@@ -11,6 +9,7 @@ import { teamPaths } from "@/modules/api/v2/organizations/[organizationId]/teams
import { userPaths } from "@/modules/api/v2/organizations/[organizationId]/users/lib/openapi";
import { rolePaths } from "@/modules/api/v2/roles/lib/openapi";
import { bulkContactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/openapi";
import { contactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/lib/openapi";
import * as yaml from "yaml";
import { z } from "zod";
import { createDocument, extendZodWithOpenApi } from "zod-openapi";
@@ -40,8 +39,7 @@ const document = createDocument({
...mePaths,
...responsePaths,
...bulkContactPaths,
// ...contactPaths,
// ...contactAttributePaths,
...contactPaths,
...contactAttributeKeyPaths,
...surveyPaths,
...surveyContactLinksBySegmentPaths,

View File

@@ -51,6 +51,7 @@ export const ZAuditAction = z.enum([
"emailVerificationAttempted",
"userSignedOut",
"passwordReset",
"bulkCreated",
]);
export const ZActor = z.enum(["user", "api", "system"]);
export const ZAuditStatus = z.enum(["success", "failure"]);

View File

@@ -12,30 +12,48 @@ export const PUT = async (request: Request) =>
schemas: {
body: ZContactBulkUploadRequest,
},
handler: async ({ authentication, parsedInput }) => {
handler: async ({ authentication, parsedInput, auditLog }) => {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return handleApiError(request, {
type: "forbidden",
details: [{ field: "error", issue: "Contacts are not enabled for this environment." }],
});
return handleApiError(
request,
{
type: "forbidden",
details: [{ field: "error", issue: "Contacts are not enabled for this environment." }],
},
auditLog
);
}
const environmentId = parsedInput.body?.environmentId;
if (!environmentId) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "environmentId", issue: "missing" }],
});
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "environmentId", issue: "missing" }],
},
auditLog
);
}
const { contacts } = parsedInput.body ?? { contacts: [] };
if (!hasPermission(authentication.environmentPermissions, environmentId, "PUT")) {
return handleApiError(request, {
type: "unauthorized",
});
return handleApiError(
request,
{
type: "forbidden",
details: [
{
field: "environmentId",
issue: "insufficient permissions to create contact in this environment",
},
],
},
auditLog
);
}
const emails = contacts.map(
@@ -45,7 +63,7 @@ export const PUT = async (request: Request) =>
const upsertBulkContactsResult = await upsertBulkContacts(contacts, environmentId, emails);
if (!upsertBulkContactsResult.ok) {
return handleApiError(request, upsertBulkContactsResult.error);
return handleApiError(request, upsertBulkContactsResult.error, auditLog);
}
const { contactIdxWithConflictingUserIds } = upsertBulkContactsResult.data;
@@ -73,4 +91,6 @@ export const PUT = async (request: Request) =>
},
});
},
action: "bulkCreated",
targetType: "contact",
});

View File

@@ -0,0 +1,340 @@
import { TContactCreateRequest } from "@/modules/ee/contacts/types/contact";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { createContact } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
create: vi.fn(),
},
contactAttributeKey: {
findMany: vi.fn(),
},
},
}));
describe("contact.ts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createContact", () => {
test("returns bad_request error when email attribute is missing", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
firstName: "John",
},
};
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details).toEqual([{ field: "attributes", issue: "email attribute is required" }]);
}
});
test("returns bad_request error when email attribute value is empty", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "",
},
};
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details).toEqual([{ field: "attributes", issue: "email attribute is required" }]);
}
});
test("returns bad_request error when attribute keys do not exist", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
nonExistentKey: "value",
},
};
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ id: "attr1", key: "email", name: "Email", type: "default", environmentId: "env123" },
] as TContactAttributeKey[]);
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details).toEqual([
{ field: "attributes", issue: "attribute keys not found: nonExistentKey. " },
]);
}
});
test("returns conflict error when contact with same email already exists", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
},
};
vi.mocked(prisma.contact.findFirst).mockResolvedValueOnce({
id: "existing-contact-id",
environmentId: "env123",
userId: null,
createdAt: new Date(),
updatedAt: new Date(),
});
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("conflict");
expect(result.error.details).toEqual([
{ field: "email", issue: "contact with this email already exists" },
]);
}
});
test("returns conflict error when contact with same userId already exists", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
userId: "user123",
},
};
vi.mocked(prisma.contact.findFirst)
.mockResolvedValueOnce(null) // No existing contact by email
.mockResolvedValueOnce({
id: "existing-contact-id",
environmentId: "env123",
userId: "user123",
createdAt: new Date(),
updatedAt: new Date(),
}); // Existing contact by userId
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("conflict");
expect(result.error.details).toEqual([
{ field: "userId", issue: "contact with this userId already exists" },
]);
}
});
test("successfully creates contact with existing attribute keys", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
firstName: "John",
},
};
const existingAttributeKeys = [
{ id: "attr1", key: "email", name: "Email", type: "default", environmentId: "env123" },
{ id: "attr2", key: "firstName", name: "First Name", type: "custom", environmentId: "env123" },
] as TContactAttributeKey[];
const contactWithAttributes = {
id: "contact123",
environmentId: "env123",
createdAt: new Date("2023-01-01T00:00:00.000Z"),
updatedAt: new Date("2023-01-01T00:00:00.000Z"),
userId: null,
attributes: [
{
attributeKey: existingAttributeKeys[0],
value: "john@example.com",
},
{
attributeKey: existingAttributeKeys[1],
value: "John",
},
],
};
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(existingAttributeKeys);
vi.mocked(prisma.contact.create).mockResolvedValue(contactWithAttributes);
const result = await createContact(contactData);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
id: "contact123",
createdAt: new Date("2023-01-01T00:00:00.000Z"),
environmentId: "env123",
attributes: {
email: "john@example.com",
firstName: "John",
},
});
}
});
test("returns internal_server_error when contact creation returns null", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
},
};
const existingAttributeKeys = [
{ id: "attr1", key: "email", name: "Email", type: "default", environmentId: "env123" },
] as TContactAttributeKey[];
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(existingAttributeKeys);
vi.mocked(prisma.contact.create).mockResolvedValue(null as any);
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "contact", issue: "Cannot read properties of null (reading 'attributes')" },
]);
}
});
test("returns internal_server_error when database error occurs", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
},
};
vi.mocked(prisma.contact.findFirst).mockRejectedValue(new Error("Database connection failed"));
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "contact", issue: "Database connection failed" }]);
}
});
test("does not check for userId conflict when userId is not provided", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
},
};
const existingAttributeKeys = [
{ id: "attr1", key: "email", name: "Email", type: "default", environmentId: "env123" },
] as TContactAttributeKey[];
const contactWithAttributes = {
id: "contact123",
environmentId: "env123",
createdAt: new Date("2023-01-01T00:00:00.000Z"),
updatedAt: new Date("2023-01-01T00:00:00.000Z"),
userId: null,
attributes: [
{
attributeKey: existingAttributeKeys[0],
value: "john@example.com",
},
],
};
vi.mocked(prisma.contact.findFirst).mockResolvedValueOnce(null); // No existing contact by email
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(existingAttributeKeys);
vi.mocked(prisma.contact.create).mockResolvedValue(contactWithAttributes);
const result = await createContact(contactData);
expect(result.ok).toBe(true);
expect(prisma.contact.findFirst).toHaveBeenCalledTimes(1); // Only called once for email check
});
test("returns bad_request error when multiple attribute keys are missing", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
nonExistentKey1: "value1",
nonExistentKey2: "value2",
},
};
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ id: "attr1", key: "email", name: "Email", type: "default", environmentId: "env123" },
] as TContactAttributeKey[]);
const result = await createContact(contactData);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details).toEqual([
{ field: "attributes", issue: "attribute keys not found: nonExistentKey1, nonExistentKey2. " },
]);
}
});
test("correctly handles userId extraction from attributes", async () => {
const contactData: TContactCreateRequest = {
environmentId: "env123",
attributes: {
email: "john@example.com",
userId: "user123",
firstName: "John",
},
};
const existingAttributeKeys = [
{ id: "attr1", key: "email", name: "Email", type: "default", environmentId: "env123" },
{ id: "attr2", key: "userId", name: "User ID", type: "default", environmentId: "env123" },
{ id: "attr3", key: "firstName", name: "First Name", type: "custom", environmentId: "env123" },
] as TContactAttributeKey[];
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(existingAttributeKeys);
const contactWithAttributes = {
id: "contact123",
environmentId: "env123",
createdAt: new Date("2023-01-01T00:00:00.000Z"),
updatedAt: new Date("2023-01-01T00:00:00.000Z"),
userId: null,
attributes: [
{ attributeKey: existingAttributeKeys[0], value: "john@example.com" },
{ attributeKey: existingAttributeKeys[1], value: "user123" },
{ attributeKey: existingAttributeKeys[2], value: "John" },
],
};
vi.mocked(prisma.contact.create).mockResolvedValue(contactWithAttributes);
const result = await createContact(contactData);
expect(result.ok).toBe(true);
expect(prisma.contact.findFirst).toHaveBeenCalledTimes(2); // Called once for email check and once for userId check
});
});
});

View File

@@ -0,0 +1,138 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { TContactCreateRequest, TContactResponse } from "@/modules/ee/contacts/types/contact";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const createContact = async (
contactData: TContactCreateRequest
): Promise<Result<TContactResponse, ApiErrorResponseV2>> => {
const { environmentId, attributes } = contactData;
try {
const emailValue = attributes.email;
if (!emailValue) {
return err({
type: "bad_request",
details: [{ field: "attributes", issue: "email attribute is required" }],
});
}
// Extract userId if present
const userId = attributes.userId;
// Check for existing contact with same email
const existingContactByEmail = await prisma.contact.findFirst({
where: {
environmentId,
attributes: {
some: {
attributeKey: { key: "email" },
value: emailValue,
},
},
},
});
if (existingContactByEmail) {
return err({
type: "conflict",
details: [{ field: "email", issue: "contact with this email already exists" }],
});
}
// Check for existing contact with same userId (if provided)
if (userId) {
const existingContactByUserId = await prisma.contact.findFirst({
where: {
environmentId,
attributes: {
some: {
attributeKey: { key: "userId" },
value: userId,
},
},
},
});
if (existingContactByUserId) {
return err({
type: "conflict",
details: [{ field: "userId", issue: "contact with this userId already exists" }],
});
}
}
// Get all attribute keys that need to exist
const attributeKeys = Object.keys(attributes);
// Check which attribute keys exist in the environment
const existingAttributeKeys = await prisma.contactAttributeKey.findMany({
where: {
environmentId,
key: { in: attributeKeys },
},
});
const existingKeySet = new Set(existingAttributeKeys.map((key) => key.key));
// Identify missing attribute keys
const missingKeys = attributeKeys.filter((key) => !existingKeySet.has(key));
// If any keys are missing, return an error
if (missingKeys.length > 0) {
return err({
type: "bad_request",
details: [{ field: "attributes", issue: `attribute keys not found: ${missingKeys.join(", ")}. ` }],
});
}
const attributeData = Object.entries(attributes).map(([key, value]) => {
const attributeKey = existingAttributeKeys.find((ak) => ak.key === key)!;
return {
attributeKeyId: attributeKey.id,
value,
};
});
const result = await prisma.contact.create({
data: {
environmentId,
attributes: {
createMany: {
data: attributeData,
},
},
},
select: {
id: true,
createdAt: true,
environmentId: true,
attributes: {
include: {
attributeKey: true,
},
},
},
});
// Format the response with flattened attributes
const flattenedAttributes: Record<string, string> = {};
result.attributes.forEach((attr) => {
flattenedAttributes[attr.attributeKey.key] = attr.value;
});
const response: TContactResponse = {
id: result.id,
createdAt: result.createdAt,
environmentId: result.environmentId,
attributes: flattenedAttributes,
};
return ok(response);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "contact", issue: error.message }],
});
}
};

View File

@@ -0,0 +1,61 @@
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZContactCreateRequest, ZContactResponse } from "@/modules/ee/contacts/types/contact";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
export const createContactEndpoint: ZodOpenApiOperationObject = {
operationId: "createContact",
summary: "Create a contact",
description:
"Creates a contact in the database. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.",
tags: ["Management API - Contacts"],
requestBody: {
required: true,
description:
"The contact to create. Must include an email attribute and all attribute keys must already exist in the environment.",
content: {
"application/json": {
schema: ZContactCreateRequest,
example: {
environmentId: "env_01h2xce9q8p3w4x5y6z7a8b9c0",
attributes: {
email: "john.doe@example.com",
firstName: "John",
lastName: "Doe",
userId: "h2xce9q8p3w4x5y6z7a8b9c1",
},
},
},
},
},
responses: {
"201": {
description: "Contact created successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZContactResponse),
example: {
id: "ctc_01h2xce9q8p3w4x5y6z7a8b9c2",
createdAt: "2023-01-01T12:00:00.000Z",
environmentId: "env_01h2xce9q8p3w4x5y6z7a8b9c0",
attributes: {
email: "john.doe@example.com",
firstName: "John",
lastName: "Doe",
userId: "h2xce9q8p3w4x5y6z7a8b9c1",
},
},
},
},
},
},
};
export const contactPaths: ZodOpenApiPathsObject = {
"/contacts": {
servers: managementServer,
post: createContactEndpoint,
},
};

View File

@@ -0,0 +1,66 @@
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 { createContact } from "@/modules/ee/contacts/api/v2/management/contacts/lib/contact";
import { ZContactCreateRequest } from "@/modules/ee/contacts/types/contact";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
export const POST = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
body: ZContactCreateRequest,
},
handler: async ({ authentication, parsedInput, auditLog }) => {
const { body } = parsedInput;
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
return handleApiError(
request,
{
type: "forbidden",
details: [{ field: "contacts", issue: "Contacts feature is not enabled for this environment" }],
},
auditLog
);
}
const { environmentId } = body;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return handleApiError(
request,
{
type: "forbidden",
details: [
{
field: "environmentId",
issue: "insufficient permissions to create contact in this environment",
},
],
},
auditLog
);
}
const createContactResult = await createContact(body);
if (!createContactResult.ok) {
return handleApiError(request, createContactResult.error, auditLog);
}
const createdContact = createContactResult.data;
if (auditLog) {
auditLog.targetId = createdContact.id;
auditLog.newObject = createdContact;
}
return responses.createdResponse(createContactResult);
},
action: "created",
targetType: "contact",
});

View File

@@ -0,0 +1,708 @@
import { describe, expect, test } from "vitest";
import { ZodError } from "zod";
import {
ZContact,
ZContactBulkUploadRequest,
ZContactCSVAttributeMap,
ZContactCSVUploadResponse,
ZContactCreateRequest,
ZContactResponse,
ZContactTableData,
ZContactWithAttributes,
validateEmailAttribute,
validateUniqueAttributeKeys,
} from "./contact";
describe("ZContact", () => {
test("should validate valid contact data", () => {
const validContact = {
id: "cld1234567890abcdef123456",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "cld1234567890abcdef123456",
};
const result = ZContact.parse(validContact);
expect(result).toEqual(validContact);
});
test("should reject invalid contact data", () => {
const invalidContact = {
id: "invalid-id",
createdAt: "invalid-date",
updatedAt: new Date(),
environmentId: "cld1234567890abcdef123456",
};
expect(() => ZContact.parse(invalidContact)).toThrow(ZodError);
});
});
describe("ZContactTableData", () => {
test("should validate valid contact table data", () => {
const validData = {
id: "cld1234567890abcdef123456",
userId: "user123",
email: "test@example.com",
firstName: "John",
lastName: "Doe",
attributes: [
{
key: "attr1",
name: "Attribute 1",
value: "value1",
},
],
};
const result = ZContactTableData.parse(validData);
expect(result).toEqual(validData);
});
test("should handle nullable names and values in attributes", () => {
const validData = {
id: "cld1234567890abcdef123456",
userId: "user123",
email: "test@example.com",
firstName: "John",
lastName: "Doe",
attributes: [
{
key: "attr1",
name: null,
value: null,
},
],
};
const result = ZContactTableData.parse(validData);
expect(result).toEqual(validData);
});
});
describe("ZContactWithAttributes", () => {
test("should validate contact with attributes", () => {
const validData = {
id: "cld1234567890abcdef123456",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "cld1234567890abcdef123456",
attributes: {
email: "test@example.com",
firstName: "John",
},
};
const result = ZContactWithAttributes.parse(validData);
expect(result).toEqual(validData);
});
});
describe("ZContactCSVUploadResponse", () => {
test("should validate valid CSV upload data", () => {
const validData = [
{
email: "test1@example.com",
firstName: "John",
lastName: "Doe",
},
{
email: "test2@example.com",
firstName: "Jane",
lastName: "Smith",
},
];
const result = ZContactCSVUploadResponse.parse(validData);
expect(result).toEqual(validData);
});
test("should reject data without email field", () => {
const invalidData = [
{
firstName: "John",
lastName: "Doe",
},
];
expect(() => ZContactCSVUploadResponse.parse(invalidData)).toThrow(ZodError);
});
test("should reject data with empty email", () => {
const invalidData = [
{
email: "",
firstName: "John",
lastName: "Doe",
},
];
expect(() => ZContactCSVUploadResponse.parse(invalidData)).toThrow(ZodError);
});
test("should reject data with duplicate emails", () => {
const invalidData = [
{
email: "test@example.com",
firstName: "John",
lastName: "Doe",
},
{
email: "test@example.com",
firstName: "Jane",
lastName: "Smith",
},
];
expect(() => ZContactCSVUploadResponse.parse(invalidData)).toThrow(ZodError);
});
test("should reject data with duplicate userIds", () => {
const invalidData = [
{
email: "test1@example.com",
userId: "user123",
firstName: "John",
lastName: "Doe",
},
{
email: "test2@example.com",
userId: "user123",
firstName: "Jane",
lastName: "Smith",
},
];
expect(() => ZContactCSVUploadResponse.parse(invalidData)).toThrow(ZodError);
});
test("should reject data exceeding 10000 records", () => {
const invalidData = Array.from({ length: 10001 }, (_, i) => ({
email: `test${i}@example.com`,
firstName: "John",
lastName: "Doe",
}));
expect(() => ZContactCSVUploadResponse.parse(invalidData)).toThrow(ZodError);
});
});
describe("ZContactCSVAttributeMap", () => {
test("should validate valid attribute map", () => {
const validMap = {
firstName: "first_name",
lastName: "last_name",
email: "email_address",
};
const result = ZContactCSVAttributeMap.parse(validMap);
expect(result).toEqual(validMap);
});
test("should reject attribute map with duplicate values", () => {
const invalidMap = {
firstName: "name",
lastName: "name",
email: "email",
};
expect(() => ZContactCSVAttributeMap.parse(invalidMap)).toThrow(ZodError);
});
});
describe("ZContactBulkUploadRequest", () => {
test("should validate valid bulk upload request", () => {
const validRequest = {
environmentId: "cld1234567890abcdef123456",
contacts: [
{
attributes: [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "test@example.com",
},
],
},
],
};
const result = ZContactBulkUploadRequest.parse(validRequest);
expect(result).toEqual(validRequest);
});
test("should reject request without email attribute", () => {
const invalidRequest = {
environmentId: "cld1234567890abcdef123456",
contacts: [
{
attributes: [
{
attributeKey: {
key: "firstName",
name: "First Name",
},
value: "John",
},
],
},
],
};
expect(() => ZContactBulkUploadRequest.parse(invalidRequest)).toThrow(ZodError);
});
test("should reject request with empty email value", () => {
const invalidRequest = {
environmentId: "cld1234567890abcdef123456",
contacts: [
{
attributes: [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "",
},
],
},
],
};
expect(() => ZContactBulkUploadRequest.parse(invalidRequest)).toThrow(ZodError);
});
test("should reject request with invalid email format", () => {
const invalidRequest = {
environmentId: "cld1234567890abcdef123456",
contacts: [
{
attributes: [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "invalid-email",
},
],
},
],
};
expect(() => ZContactBulkUploadRequest.parse(invalidRequest)).toThrow(ZodError);
});
test("should reject request with duplicate emails across contacts", () => {
const invalidRequest = {
environmentId: "cld1234567890abcdef123456",
contacts: [
{
attributes: [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "test@example.com",
},
],
},
{
attributes: [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "test@example.com",
},
],
},
],
};
expect(() => ZContactBulkUploadRequest.parse(invalidRequest)).toThrow(ZodError);
});
test("should reject request with duplicate userIds across contacts", () => {
const invalidRequest = {
environmentId: "cld1234567890abcdef123456",
contacts: [
{
attributes: [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "test1@example.com",
},
{
attributeKey: {
key: "userId",
name: "User ID",
},
value: "user123",
},
],
},
{
attributes: [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "test2@example.com",
},
{
attributeKey: {
key: "userId",
name: "User ID",
},
value: "user123",
},
],
},
],
};
expect(() => ZContactBulkUploadRequest.parse(invalidRequest)).toThrow(ZodError);
});
test("should reject request with duplicate attribute keys within same contact", () => {
const invalidRequest = {
environmentId: "cld1234567890abcdef123456",
contacts: [
{
attributes: [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "test@example.com",
},
{
attributeKey: {
key: "email",
name: "Email Duplicate",
},
value: "test2@example.com",
},
],
},
],
};
expect(() => ZContactBulkUploadRequest.parse(invalidRequest)).toThrow(ZodError);
});
test("should reject request exceeding 250 contacts", () => {
const invalidRequest = {
environmentId: "cld1234567890abcdef123456",
contacts: Array.from({ length: 251 }, (_, i) => ({
attributes: [
{
attributeKey: {
key: "email",
name: "Email",
},
value: `test${i}@example.com`,
},
],
})),
};
expect(() => ZContactBulkUploadRequest.parse(invalidRequest)).toThrow(ZodError);
});
});
describe("ZContactCreateRequest", () => {
test("should validate valid create request with simplified flat attributes", () => {
const validRequest = {
environmentId: "cld1234567890abcdef123456",
attributes: {
email: "test@example.com",
firstName: "John",
lastName: "Doe",
},
};
const result = ZContactCreateRequest.parse(validRequest);
expect(result).toEqual(validRequest);
});
test("should validate create request with only email attribute", () => {
const validRequest = {
environmentId: "cld1234567890abcdef123456",
attributes: {
email: "test@example.com",
},
};
const result = ZContactCreateRequest.parse(validRequest);
expect(result).toEqual(validRequest);
});
test("should reject create request without email attribute", () => {
const invalidRequest = {
environmentId: "cld1234567890abcdef123456",
attributes: {
firstName: "John",
lastName: "Doe",
},
};
expect(() => ZContactCreateRequest.parse(invalidRequest)).toThrow(ZodError);
});
test("should reject create request with invalid email format", () => {
const invalidRequest = {
environmentId: "cld1234567890abcdef123456",
attributes: {
email: "invalid-email",
firstName: "John",
},
};
expect(() => ZContactCreateRequest.parse(invalidRequest)).toThrow(ZodError);
});
test("should reject create request with empty email", () => {
const invalidRequest = {
environmentId: "cld1234567890abcdef123456",
attributes: {
email: "",
firstName: "John",
},
};
expect(() => ZContactCreateRequest.parse(invalidRequest)).toThrow(ZodError);
});
test("should reject create request with invalid environmentId", () => {
const invalidRequest = {
environmentId: "invalid-id",
attributes: {
email: "test@example.com",
},
};
expect(() => ZContactCreateRequest.parse(invalidRequest)).toThrow(ZodError);
});
});
describe("ZContactResponse", () => {
test("should validate valid contact response with flat string attributes", () => {
const validResponse = {
id: "cld1234567890abcdef123456",
createdAt: new Date(),
environmentId: "cld1234567890abcdef123456",
attributes: {
email: "test@example.com",
firstName: "John",
lastName: "Doe",
},
};
const result = ZContactResponse.parse(validResponse);
expect(result).toEqual(validResponse);
});
test("should validate contact response with only email attribute", () => {
const validResponse = {
id: "cld1234567890abcdef123456",
createdAt: new Date(),
environmentId: "cld1234567890abcdef123456",
attributes: {
email: "test@example.com",
},
};
const result = ZContactResponse.parse(validResponse);
expect(result).toEqual(validResponse);
});
test("should reject contact response with null attribute values", () => {
const invalidResponse = {
id: "cld1234567890abcdef123456",
createdAt: new Date(),
environmentId: "cld1234567890abcdef123456",
attributes: {
email: "test@example.com",
firstName: "John",
lastName: null,
},
};
expect(() => ZContactResponse.parse(invalidResponse)).toThrow(ZodError);
});
test("should reject contact response with invalid id format", () => {
const invalidResponse = {
id: "invalid-id",
createdAt: new Date(),
environmentId: "cld1234567890abcdef123456",
attributes: {
email: "test@example.com",
},
};
expect(() => ZContactResponse.parse(invalidResponse)).toThrow(ZodError);
});
test("should reject contact response with invalid environmentId format", () => {
const invalidResponse = {
id: "cld1234567890abcdef123456",
createdAt: new Date(),
environmentId: "invalid-env-id",
attributes: {
email: "test@example.com",
},
};
expect(() => ZContactResponse.parse(invalidResponse)).toThrow(ZodError);
});
});
describe("validateEmailAttribute", () => {
test("should validate email attribute successfully", () => {
const attributes = [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "test@example.com",
},
];
const mockCtx = {
addIssue: () => {},
} as any;
const result = validateEmailAttribute(attributes, mockCtx);
expect(result.isValid).toBe(true);
expect(result.emailAttr).toEqual(attributes[0]);
});
test("should fail validation when email attribute is missing", () => {
const attributes = [
{
attributeKey: {
key: "firstName",
name: "First Name",
},
value: "John",
},
];
const mockCtx = {
addIssue: () => {},
} as any;
const result = validateEmailAttribute(attributes, mockCtx);
expect(result.isValid).toBe(false);
expect(result.emailAttr).toBeUndefined();
});
test("should fail validation when email value is empty", () => {
const attributes = [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "",
},
];
const mockCtx = {
addIssue: () => {},
} as any;
const result = validateEmailAttribute(attributes, mockCtx);
expect(result.isValid).toBe(false);
});
test("should fail validation when email format is invalid", () => {
const attributes = [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "invalid-email",
},
];
const mockCtx = {
addIssue: () => {},
} as any;
const result = validateEmailAttribute(attributes, mockCtx);
expect(result.isValid).toBe(false);
});
test("should include contact index in error messages when provided", () => {
const attributes = [
{
attributeKey: {
key: "firstName",
name: "First Name",
},
value: "John",
},
];
const mockCtx = {
addIssue: () => {},
} as any;
const result = validateEmailAttribute(attributes, mockCtx, 5);
expect(result.isValid).toBe(false);
});
});
describe("validateUniqueAttributeKeys", () => {
test("should pass validation for unique attribute keys", () => {
const attributes = [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "test@example.com",
},
{
attributeKey: {
key: "firstName",
name: "First Name",
},
value: "John",
},
];
const mockCtx = {
addIssue: () => {},
} as any;
// Should not throw or call addIssue
validateUniqueAttributeKeys(attributes, mockCtx);
});
test("should fail validation for duplicate attribute keys", () => {
const attributes = [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "test@example.com",
},
{
attributeKey: {
key: "email",
name: "Email Duplicate",
},
value: "test2@example.com",
},
];
let issueAdded = false;
const mockCtx = {
addIssue: () => {
issueAdded = true;
},
} as any;
validateUniqueAttributeKeys(attributes, mockCtx);
expect(issueAdded).toBe(true);
});
test("should include contact index in error messages when provided", () => {
const attributes = [
{
attributeKey: {
key: "email",
name: "Email",
},
value: "test@example.com",
},
{
attributeKey: {
key: "email",
name: "Email Duplicate",
},
value: "test2@example.com",
},
];
let issueAdded = false;
const mockCtx = {
addIssue: () => {
issueAdded = true;
},
} as any;
validateUniqueAttributeKeys(attributes, mockCtx, 3);
expect(issueAdded).toBe(true);
});
});

View File

@@ -122,6 +122,68 @@ export const ZContactBulkUploadContact = z.object({
export type TContactBulkUploadContact = z.infer<typeof ZContactBulkUploadContact>;
// Helper functions for common validation logic
export const validateEmailAttribute = (
attributes: z.infer<typeof ZContactBulkUploadAttribute>[],
ctx: z.RefinementCtx,
contactIndex?: number
): { emailAttr?: z.infer<typeof ZContactBulkUploadAttribute>; isValid: boolean } => {
const emailAttr = attributes.find((attr) => attr.attributeKey.key === "email");
const indexSuffix = contactIndex !== undefined ? ` for contact at index ${contactIndex}` : "";
if (!emailAttr?.value) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Email attribute is required${indexSuffix}`,
});
return { isValid: false };
}
// Check email format
const parsedEmail = z.string().email().safeParse(emailAttr.value);
if (!parsedEmail.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid email format${indexSuffix}`,
});
return { emailAttr, isValid: false };
}
return { emailAttr, isValid: true };
};
export const validateUniqueAttributeKeys = (
attributes: z.infer<typeof ZContactBulkUploadAttribute>[],
ctx: z.RefinementCtx,
contactIndex?: number
) => {
const keyOccurrences = new Map<string, number>();
const duplicateKeys: string[] = [];
attributes.forEach((attr) => {
const key = attr.attributeKey.key;
const count = (keyOccurrences.get(key) ?? 0) + 1;
keyOccurrences.set(key, count);
// If this is the second occurrence, add to duplicates
if (count === 2) {
duplicateKeys.push(key);
}
});
if (duplicateKeys.length > 0) {
const indexSuffix = contactIndex !== undefined ? ` for contact at index ${contactIndex}` : "";
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate attribute keys found${indexSuffix}. Please ensure each attribute key is unique`,
params: {
duplicateKeys,
...(contactIndex !== undefined && { contactIndex }),
},
});
}
};
export const ZContactBulkUploadRequest = z.object({
environmentId: z.string().cuid2(),
contacts: z
@@ -133,28 +195,14 @@ export const ZContactBulkUploadRequest = z.object({
const duplicateEmails = new Set<string>();
const seenUserIds = new Set<string>();
const duplicateUserIds = new Set<string>();
const contactsWithDuplicateKeys: { idx: number; duplicateKeys: string[] }[] = [];
// Process each contact in a single pass
contacts.forEach((contact, idx) => {
// 1. Check email existence and validity
const emailAttr = contact.attributes.find((attr) => attr.attributeKey.key === "email");
if (!emailAttr?.value) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Missing email attribute for contact at index ${idx}`,
});
} else {
// Check email format
const parsedEmail = z.string().email().safeParse(emailAttr.value);
if (!parsedEmail.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid email for contact at index ${idx}`,
});
}
// 1. Check email existence and validity using helper function
const { emailAttr, isValid } = validateEmailAttribute(contact.attributes, ctx, idx);
// Check for duplicate emails
if (isValid && emailAttr) {
// Check for duplicate emails across contacts
if (seenEmails.has(emailAttr.value)) {
duplicateEmails.add(emailAttr.value);
} else {
@@ -172,24 +220,8 @@ export const ZContactBulkUploadRequest = z.object({
}
}
// 3. Check for duplicate attribute keys within the same contact
const keyOccurrences = new Map<string, number>();
const duplicateKeysForContact: string[] = [];
contact.attributes.forEach((attr) => {
const key = attr.attributeKey.key;
const count = (keyOccurrences.get(key) || 0) + 1;
keyOccurrences.set(key, count);
// If this is the second occurrence, add to duplicates
if (count === 2) {
duplicateKeysForContact.push(key);
}
});
if (duplicateKeysForContact.length > 0) {
contactsWithDuplicateKeys.push({ idx, duplicateKeys: duplicateKeysForContact });
}
// 3. Check for duplicate attribute keys within the same contact using helper function
validateUniqueAttributeKeys(contact.attributes, ctx, idx);
});
// Report all validation issues after the single pass
@@ -212,17 +244,6 @@ export const ZContactBulkUploadRequest = z.object({
},
});
}
if (contactsWithDuplicateKeys.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Duplicate attribute keys found in the records, please ensure each attribute key is unique.",
params: {
contactsWithDuplicateKeys,
},
});
}
}),
});
@@ -243,3 +264,39 @@ export type TContactBulkUploadResponseSuccess = TContactBulkUploadResponseBase &
processed: number;
failed: number;
};
// Schema for single contact creation - simplified with flat attributes
export const ZContactCreateRequest = z.object({
environmentId: z.string().cuid2(),
attributes: z.record(z.string(), z.string()).superRefine((attributes, ctx) => {
// Check if email attribute exists and is valid
const email = attributes.email;
if (!email) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Email attribute is required",
});
} else {
// Check email format
const parsedEmail = z.string().email().safeParse(email);
if (!parsedEmail.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid email format",
});
}
}
}),
});
export type TContactCreateRequest = z.infer<typeof ZContactCreateRequest>;
// Type for contact response with flattened attributes
export const ZContactResponse = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
environmentId: z.string().cuid2(),
attributes: z.record(z.string(), z.string()),
});
export type TContactResponse = z.infer<typeof ZContactResponse>;

View File

@@ -13,9 +13,9 @@
"lint": "next lint",
"test": "dotenv -e ../../.env -- vitest run",
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
"generate-api-specs": "dotenv -e ../../.env tsx ./modules/api/v2/openapi-document.ts > ../../docs/api-v2-reference/openapi.yml",
"generate-api-specs": "./scripts/openapi/generate.sh",
"merge-client-endpoints": "tsx ./scripts/openapi/merge-client-endpoints.ts",
"generate-and-merge-api-specs": "npm run generate-api-specs && npm run merge-client-endpoints"
"generate-and-merge-api-specs": "pnpm run generate-api-specs && pnpm run merge-client-endpoints"
},
"dependencies": {
"@aws-sdk/client-s3": "3.804.0",
@@ -160,6 +160,7 @@
"@vitest/coverage-v8": "3.1.3",
"autoprefixer": "10.4.21",
"dotenv": "16.5.0",
"esbuild": "0.25.4",
"postcss": "8.5.3",
"resize-observer-polyfill": "1.5.1",
"ts-node": "10.9.2",

View File

@@ -0,0 +1,161 @@
import { expect } from "@playwright/test";
import { test } from "../../lib/fixtures";
import { loginAndGetApiKey } from "../../lib/utils";
test.describe("API Tests for Single Contact Creation", () => {
test("Create and Test Contact Creation via API", async ({ page, users, request }) => {
let environmentId, apiKey;
try {
({ environmentId, apiKey } = await loginAndGetApiKey(page, users));
} catch (error) {
console.error("Error during login and getting API key:", error);
throw error;
}
const baseEmail = `test-${Date.now()}`;
await test.step("Create contact successfully with email only", async () => {
const uniqueEmail = `${baseEmail}-single@example.com`;
const response = await request.post("/api/v2/management/contacts", {
headers: { "x-api-key": apiKey },
data: {
environmentId,
attributes: {
email: uniqueEmail,
},
},
});
expect(response.status()).toBe(201);
const contactData = await response.json();
expect(contactData.data).toBeDefined();
expect(contactData.data.id).toMatch(/^[a-z0-9]{25}$/); // CUID2 format
expect(contactData.data.environmentId).toBe(environmentId);
expect(contactData.data.attributes.email).toBe(uniqueEmail);
expect(contactData.data.createdAt).toBeDefined();
});
await test.step("Create contact successfully with multiple attributes", async () => {
const uniqueEmail = `${baseEmail}-multi@example.com`;
const uniqueUserId = `usr_${Date.now()}`;
const response = await request.post("/api/v2/management/contacts", {
headers: { "x-api-key": apiKey },
data: {
environmentId,
attributes: {
email: uniqueEmail,
firstName: "John",
lastName: "Doe",
userId: uniqueUserId,
},
},
});
expect(response.status()).toBe(201);
const contactData = await response.json();
expect(contactData.data.attributes.email).toBe(uniqueEmail);
expect(contactData.data.attributes.firstName).toBe("John");
expect(contactData.data.attributes.lastName).toBe("Doe");
expect(contactData.data.attributes.userId).toBe(uniqueUserId);
});
await test.step("Return error for missing attribute keys", async () => {
const uniqueEmail = `${baseEmail}-newkey@example.com`;
const customKey = `customAttribute_${Date.now()}`;
const response = await request.post("/api/v2/management/contacts", {
headers: { "x-api-key": apiKey },
data: {
environmentId,
attributes: {
email: uniqueEmail,
[customKey]: "custom value",
},
},
});
expect(response.status()).toBe(400);
const errorData = await response.json();
expect(errorData.error.details[0].field).toBe("attributes");
expect(errorData.error.details[0].issue).toContain("attribute keys not found");
expect(errorData.error.details[0].issue).toContain(customKey);
});
await test.step("Prevent duplicate email addresses", async () => {
const duplicateEmail = `${baseEmail}-duplicate@example.com`;
// Create first contact
const firstResponse = await request.post("/api/v2/management/contacts", {
headers: { "x-api-key": apiKey },
data: {
environmentId,
attributes: {
email: duplicateEmail,
},
},
});
expect(firstResponse.status()).toBe(201);
// Try to create second contact with same email
const secondResponse = await request.post("/api/v2/management/contacts", {
headers: { "x-api-key": apiKey },
data: {
environmentId,
attributes: {
email: duplicateEmail,
},
},
});
expect(secondResponse.status()).toBe(409);
const errorData = await secondResponse.json();
expect(errorData.error.details[0].field).toBe("email");
expect(errorData.error.details[0].issue).toContain("already exists");
});
await test.step("Prevent duplicate userId", async () => {
const duplicateUserId = `usr_duplicate_${Date.now()}`;
const email1 = `${baseEmail}-userid1@example.com`;
const email2 = `${baseEmail}-userid2@example.com`;
// Create first contact
const firstResponse = await request.post("/api/v2/management/contacts", {
headers: { "x-api-key": apiKey },
data: {
environmentId,
attributes: {
email: email1,
userId: duplicateUserId,
},
},
});
expect(firstResponse.status()).toBe(201);
// Try to create second contact with same userId but different email
const secondResponse = await request.post("/api/v2/management/contacts", {
headers: { "x-api-key": apiKey },
data: {
environmentId,
attributes: {
email: email2,
userId: duplicateUserId,
},
},
});
expect(secondResponse.status()).toBe(409);
const errorData = await secondResponse.json();
expect(errorData.error.details[0].field).toBe("userId");
expect(errorData.error.details[0].issue).toContain("already exists");
});
});
});

View File

@@ -95,6 +95,24 @@ export const createUsersFixture = (page: Page, workerInfo: TestInfo): UsersFixtu
type: "development",
attributeKeys: {
create: [
{
name: "Email",
key: "email",
isUnique: true,
type: "default",
},
{
name: "First Name",
key: "firstName",
isUnique: false,
type: "default",
},
{
name: "Last Name",
key: "lastName",
isUnique: false,
type: "default",
},
{
name: "userId",
key: "userId",
@@ -108,6 +126,24 @@ export const createUsersFixture = (page: Page, workerInfo: TestInfo): UsersFixtu
type: "production",
attributeKeys: {
create: [
{
name: "Email",
key: "email",
isUnique: true,
type: "default",
},
{
name: "First Name",
key: "firstName",
isUnique: false,
type: "default",
},
{
name: "Last Name",
key: "lastName",
isUnique: false,
type: "default",
},
{
name: "userId",
key: "userId",

View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Script to generate OpenAPI documentation
# This builds the TypeScript file first to avoid module resolution issues
set -e # Exit on any error
# Get script directory and compute project root
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
APPS_WEB_DIR="$PROJECT_ROOT/apps/web"
echo "Building OpenAPI document generator..."
# Build using the permanent vite config (from apps/web directory)
cd "$APPS_WEB_DIR"
vite build --config scripts/openapi/vite.config.ts
echo "Generating OpenAPI YAML..."
# Run the built file and output to YAML
dotenv -e "$PROJECT_ROOT/.env" -- node dist/openapi-document.js > "$PROJECT_ROOT/docs/api-v2-reference/openapi.yml"
echo "OpenAPI documentation generated successfully at docs/api-v2-reference/openapi.yml"

View File

@@ -0,0 +1,23 @@
import { resolve } from "node:path";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, "../../modules/api/v2/openapi-document.ts"),
name: "openapiDocument",
fileName: "openapi-document",
formats: ["cjs"],
},
rollupOptions: {
external: ["@prisma/client", "yaml", "zod", "zod-openapi"],
output: {
exports: "named",
},
},
outDir: "dist",
emptyOutDir: true,
},
plugins: [tsconfigPaths()],
});

View File

@@ -1658,6 +1658,69 @@ paths:
- skippedContacts
required:
- data
/contacts:
servers: *a6
post:
operationId: createContact
summary: Create a contact
description: Creates a contact in the database. Each contact must have a valid
email address in the attributes. All attribute keys must already exist
in the environment. The email is used as the unique identifier along
with the environment.
tags:
- Management API - Contacts
requestBody:
required: true
description: The contact to create. Must include an email attribute and all
attribute keys must already exist in the environment.
content:
application/json:
schema:
type: object
properties:
environmentId:
type: string
attributes:
type: object
additionalProperties:
type: string
required:
- environmentId
- attributes
example:
environmentId: env_01h2xce9q8p3w4x5y6z7a8b9c0
attributes:
email: john.doe@example.com
firstName: John
lastName: Doe
userId: h2xce9q8p3w4x5y6z7a8b9c1
responses:
"201":
description: Contact created successfully.
content:
application/json:
schema:
type: object
properties:
id:
type: string
createdAt:
type: string
environmentId:
type: string
attributes:
type: object
additionalProperties:
type: string
example:
id: ctc_01h2xce9q8p3w4x5y6z7a8b9c2
createdAt: 2023-01-01T12:00:00.000Z
environmentId: env_01h2xce9q8p3w4x5y6z7a8b9c0
attributes:
email: john.doe@example.com
firstName: John
lastName: Doe
userId: h2xce9q8p3w4x5y6z7a8b9c1
/contact-attribute-keys:
servers: *a6
get:
@@ -4017,7 +4080,6 @@ components:
type: string
buttonLink:
type: string
format: uri
imageUrl:
type: string
videoUrl:
@@ -4297,7 +4359,6 @@ components:
pattern: ^#(?:[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$
required:
- light
highlightBorderColor:
type:
- object

31
pnpm-lock.yaml generated
View File

@@ -255,7 +255,7 @@ importers:
version: 0.0.38(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@sentry/nextjs':
specifier: 9.22.0
version: 9.22.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.8)
version: 9.22.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.8(esbuild@0.25.4))
'@t3-oss/env-nextjs':
specifier: 0.13.4
version: 0.13.4(arktype@2.1.20)(typescript@5.8.3)(zod@3.24.4)
@@ -312,7 +312,7 @@ importers:
version: 4.1.0
file-loader:
specifier: 6.2.0
version: 6.2.0(webpack@5.99.8)
version: 6.2.0(webpack@5.99.8(esbuild@0.25.4))
framer-motion:
specifier: 12.10.0
version: 12.10.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -444,7 +444,7 @@ importers:
version: 11.1.0
webpack:
specifier: 5.99.8
version: 5.99.8
version: 5.99.8(esbuild@0.25.4)
xlsx:
specifier: 0.18.5
version: 0.18.5
@@ -515,6 +515,9 @@ importers:
dotenv:
specifier: 16.5.0
version: 16.5.0
esbuild:
specifier: 0.25.4
version: 0.25.4
postcss:
specifier: 8.5.3
version: 8.5.3
@@ -13268,7 +13271,7 @@ snapshots:
'@sentry/core@9.22.0': {}
'@sentry/nextjs@9.22.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.8)':
'@sentry/nextjs@9.22.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.8(esbuild@0.25.4))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.34.0
@@ -13279,7 +13282,7 @@ snapshots:
'@sentry/opentelemetry': 9.22.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.200.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.34.0)
'@sentry/react': 9.22.0(react@19.1.0)
'@sentry/vercel-edge': 9.22.0
'@sentry/webpack-plugin': 3.3.1(encoding@0.1.13)(webpack@5.99.8)
'@sentry/webpack-plugin': 3.3.1(encoding@0.1.13)(webpack@5.99.8(esbuild@0.25.4))
chalk: 3.0.0
next: 15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
resolve: 1.22.8
@@ -13366,12 +13369,12 @@ snapshots:
'@opentelemetry/api': 1.9.0
'@sentry/core': 9.22.0
'@sentry/webpack-plugin@3.3.1(encoding@0.1.13)(webpack@5.99.8)':
'@sentry/webpack-plugin@3.3.1(encoding@0.1.13)(webpack@5.99.8(esbuild@0.25.4))':
dependencies:
'@sentry/bundler-plugin-core': 3.3.1(encoding@0.1.13)
unplugin: 1.0.1
uuid: 9.0.1
webpack: 5.99.8
webpack: 5.99.8(esbuild@0.25.4)
transitivePeerDependencies:
- encoding
- supports-color
@@ -16430,11 +16433,11 @@ snapshots:
dependencies:
flat-cache: 3.2.0
file-loader@6.2.0(webpack@5.99.8):
file-loader@6.2.0(webpack@5.99.8(esbuild@0.25.4)):
dependencies:
loader-utils: 2.0.4
schema-utils: 3.3.0
webpack: 5.99.8
webpack: 5.99.8(esbuild@0.25.4)
file-uri-to-path@1.0.0: {}
@@ -19511,14 +19514,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
terser-webpack-plugin@5.3.14(webpack@5.99.8):
terser-webpack-plugin@5.3.14(esbuild@0.25.4)(webpack@5.99.8(esbuild@0.25.4)):
dependencies:
'@jridgewell/trace-mapping': 0.3.29
jest-worker: 27.5.1
schema-utils: 4.3.2
serialize-javascript: 6.0.2
terser: 5.39.1
webpack: 5.99.8
webpack: 5.99.8(esbuild@0.25.4)
optionalDependencies:
esbuild: 0.25.4
terser@5.39.1:
dependencies:
@@ -20074,7 +20079,7 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
webpack@5.99.8:
webpack@5.99.8(esbuild@0.25.4):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
@@ -20097,7 +20102,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.2
tapable: 2.2.2
terser-webpack-plugin: 5.3.14(webpack@5.99.8)
terser-webpack-plugin: 5.3.14(esbuild@0.25.4)(webpack@5.99.8(esbuild@0.25.4))
watchpack: 2.4.4
webpack-sources: 3.3.3
transitivePeerDependencies: