mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 16:16:21 -06:00
feat: add single contact using the API V2 (#6168)
This commit is contained in:
committed by
GitHub
parent
492a59e7de
commit
4e52556f7e
1
apps/web/app/api/v2/management/contacts/route.ts
Normal file
1
apps/web/app/api/v2/management/contacts/route.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { POST } from "@/modules/ee/contacts/api/v2/management/contacts/route";
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
});
|
||||
708
apps/web/modules/ee/contacts/types/contact.test.ts
Normal file
708
apps/web/modules/ee/contacts/types/contact.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
161
apps/web/playwright/api/management/contacts.spec.ts
Normal file
161
apps/web/playwright/api/management/contacts.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
24
apps/web/scripts/openapi/generate.sh
Executable file
24
apps/web/scripts/openapi/generate.sh
Executable 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"
|
||||
23
apps/web/scripts/openapi/vite.config.ts
Normal file
23
apps/web/scripts/openapi/vite.config.ts
Normal 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()],
|
||||
});
|
||||
@@ -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
31
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user