From 4e52556f7e163e89a812edeb5002fb5110f4fabe Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos <115753265+victorvhs017@users.noreply.github.com> Date: Thu, 10 Jul 2025 17:34:18 +0700 Subject: [PATCH] feat: add single contact using the API V2 (#6168) --- .../app/api/v2/management/contacts/route.ts | 1 + .../[contactAttributeId]/lib/openapi.ts | 79 -- .../contact-attributes/lib/openapi.ts | 68 -- .../types/contact-attributes.ts | 34 - .../contacts/[contactId]/lib/openapi.ts | 79 -- .../api/v2/management/contacts/lib/openapi.ts | 70 -- .../v2/management/contacts/types/contacts.ts | 40 - apps/web/modules/api/v2/openapi-document.ts | 6 +- .../modules/ee/audit-logs/types/audit-log.ts | 1 + .../api/v2/management/contacts/bulk/route.ts | 46 +- .../management/contacts/lib/contact.test.ts | 340 +++++++++ .../api/v2/management/contacts/lib/contact.ts | 138 ++++ .../api/v2/management/contacts/lib/openapi.ts | 61 ++ .../api/v2/management/contacts/route.ts | 66 ++ .../modules/ee/contacts/types/contact.test.ts | 708 ++++++++++++++++++ apps/web/modules/ee/contacts/types/contact.ts | 151 ++-- apps/web/package.json | 5 +- .../api/management/contacts.spec.ts | 161 ++++ apps/web/playwright/fixtures/users.ts | 36 + apps/web/scripts/openapi/generate.sh | 24 + apps/web/scripts/openapi/vite.config.ts | 23 + docs/api-v2-reference/openapi.yml | 65 +- pnpm-lock.yaml | 31 +- 23 files changed, 1782 insertions(+), 451 deletions(-) create mode 100644 apps/web/app/api/v2/management/contacts/route.ts delete mode 100644 apps/web/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi.ts delete mode 100644 apps/web/modules/api/v2/management/contact-attributes/lib/openapi.ts delete mode 100644 apps/web/modules/api/v2/management/contact-attributes/types/contact-attributes.ts delete mode 100644 apps/web/modules/api/v2/management/contacts/[contactId]/lib/openapi.ts delete mode 100644 apps/web/modules/api/v2/management/contacts/lib/openapi.ts delete mode 100644 apps/web/modules/api/v2/management/contacts/types/contacts.ts create mode 100644 apps/web/modules/ee/contacts/api/v2/management/contacts/lib/contact.test.ts create mode 100644 apps/web/modules/ee/contacts/api/v2/management/contacts/lib/contact.ts create mode 100644 apps/web/modules/ee/contacts/api/v2/management/contacts/lib/openapi.ts create mode 100644 apps/web/modules/ee/contacts/api/v2/management/contacts/route.ts create mode 100644 apps/web/modules/ee/contacts/types/contact.test.ts create mode 100644 apps/web/playwright/api/management/contacts.spec.ts create mode 100755 apps/web/scripts/openapi/generate.sh create mode 100644 apps/web/scripts/openapi/vite.config.ts diff --git a/apps/web/app/api/v2/management/contacts/route.ts b/apps/web/app/api/v2/management/contacts/route.ts new file mode 100644 index 0000000000..b216e7c2b9 --- /dev/null +++ b/apps/web/app/api/v2/management/contacts/route.ts @@ -0,0 +1 @@ +export { POST } from "@/modules/ee/contacts/api/v2/management/contacts/route"; diff --git a/apps/web/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi.ts b/apps/web/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi.ts deleted file mode 100644 index 55821104b5..0000000000 --- a/apps/web/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi.ts +++ /dev/null @@ -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, - }, - }, - }, - }, -}; diff --git a/apps/web/modules/api/v2/management/contact-attributes/lib/openapi.ts b/apps/web/modules/api/v2/management/contact-attributes/lib/openapi.ts deleted file mode 100644 index 59c1222dcd..0000000000 --- a/apps/web/modules/api/v2/management/contact-attributes/lib/openapi.ts +++ /dev/null @@ -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, - }, -}; diff --git a/apps/web/modules/api/v2/management/contact-attributes/types/contact-attributes.ts b/apps/web/modules/api/v2/management/contact-attributes/types/contact-attributes.ts deleted file mode 100644 index c3f3ca4fe8..0000000000 --- a/apps/web/modules/api/v2/management/contact-attributes/types/contact-attributes.ts +++ /dev/null @@ -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; diff --git a/apps/web/modules/api/v2/management/contacts/[contactId]/lib/openapi.ts b/apps/web/modules/api/v2/management/contacts/[contactId]/lib/openapi.ts deleted file mode 100644 index bdcc05648a..0000000000 --- a/apps/web/modules/api/v2/management/contacts/[contactId]/lib/openapi.ts +++ /dev/null @@ -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, - }, - }, - }, - }, -}; diff --git a/apps/web/modules/api/v2/management/contacts/lib/openapi.ts b/apps/web/modules/api/v2/management/contacts/lib/openapi.ts deleted file mode 100644 index 0d4dddc070..0000000000 --- a/apps/web/modules/api/v2/management/contacts/lib/openapi.ts +++ /dev/null @@ -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, - }, -}; diff --git a/apps/web/modules/api/v2/management/contacts/types/contacts.ts b/apps/web/modules/api/v2/management/contacts/types/contacts.ts deleted file mode 100644 index acc5b7a930..0000000000 --- a/apps/web/modules/api/v2/management/contacts/types/contacts.ts +++ /dev/null @@ -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; diff --git a/apps/web/modules/api/v2/openapi-document.ts b/apps/web/modules/api/v2/openapi-document.ts index a754323d97..f67c6a3e1d 100644 --- a/apps/web/modules/api/v2/openapi-document.ts +++ b/apps/web/modules/api/v2/openapi-document.ts @@ -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, diff --git a/apps/web/modules/ee/audit-logs/types/audit-log.ts b/apps/web/modules/ee/audit-logs/types/audit-log.ts index 862475117f..f2b70de95f 100644 --- a/apps/web/modules/ee/audit-logs/types/audit-log.ts +++ b/apps/web/modules/ee/audit-logs/types/audit-log.ts @@ -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"]); diff --git a/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts b/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts index d030a433a6..10419c38d9 100644 --- a/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts +++ b/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts @@ -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", }); diff --git a/apps/web/modules/ee/contacts/api/v2/management/contacts/lib/contact.test.ts b/apps/web/modules/ee/contacts/api/v2/management/contacts/lib/contact.test.ts new file mode 100644 index 0000000000..2c6b9e8e68 --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v2/management/contacts/lib/contact.test.ts @@ -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 + }); + }); +}); diff --git a/apps/web/modules/ee/contacts/api/v2/management/contacts/lib/contact.ts b/apps/web/modules/ee/contacts/api/v2/management/contacts/lib/contact.ts new file mode 100644 index 0000000000..d61960e0ff --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v2/management/contacts/lib/contact.ts @@ -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> => { + 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 = {}; + 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 }], + }); + } +}; diff --git a/apps/web/modules/ee/contacts/api/v2/management/contacts/lib/openapi.ts b/apps/web/modules/ee/contacts/api/v2/management/contacts/lib/openapi.ts new file mode 100644 index 0000000000..30f7ac0436 --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v2/management/contacts/lib/openapi.ts @@ -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, + }, +}; diff --git a/apps/web/modules/ee/contacts/api/v2/management/contacts/route.ts b/apps/web/modules/ee/contacts/api/v2/management/contacts/route.ts new file mode 100644 index 0000000000..bd7f0097ce --- /dev/null +++ b/apps/web/modules/ee/contacts/api/v2/management/contacts/route.ts @@ -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", + }); diff --git a/apps/web/modules/ee/contacts/types/contact.test.ts b/apps/web/modules/ee/contacts/types/contact.test.ts new file mode 100644 index 0000000000..92e3debd1d --- /dev/null +++ b/apps/web/modules/ee/contacts/types/contact.test.ts @@ -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); + }); +}); diff --git a/apps/web/modules/ee/contacts/types/contact.ts b/apps/web/modules/ee/contacts/types/contact.ts index d3bf5fb0fa..e400ad281f 100644 --- a/apps/web/modules/ee/contacts/types/contact.ts +++ b/apps/web/modules/ee/contacts/types/contact.ts @@ -122,6 +122,68 @@ export const ZContactBulkUploadContact = z.object({ export type TContactBulkUploadContact = z.infer; +// Helper functions for common validation logic +export const validateEmailAttribute = ( + attributes: z.infer[], + ctx: z.RefinementCtx, + contactIndex?: number +): { emailAttr?: z.infer; 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[], + ctx: z.RefinementCtx, + contactIndex?: number +) => { + const keyOccurrences = new Map(); + 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(); const seenUserIds = new Set(); const duplicateUserIds = new Set(); - 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(); - 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; + +// 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; diff --git a/apps/web/package.json b/apps/web/package.json index 969c9b9c70..8d9cdadba7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/playwright/api/management/contacts.spec.ts b/apps/web/playwright/api/management/contacts.spec.ts new file mode 100644 index 0000000000..a0673d4a95 --- /dev/null +++ b/apps/web/playwright/api/management/contacts.spec.ts @@ -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"); + }); + }); +}); diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 87457792ff..95b0b11046 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -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", diff --git a/apps/web/scripts/openapi/generate.sh b/apps/web/scripts/openapi/generate.sh new file mode 100755 index 0000000000..6d9688cdd4 --- /dev/null +++ b/apps/web/scripts/openapi/generate.sh @@ -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" \ No newline at end of file diff --git a/apps/web/scripts/openapi/vite.config.ts b/apps/web/scripts/openapi/vite.config.ts new file mode 100644 index 0000000000..9441b483f6 --- /dev/null +++ b/apps/web/scripts/openapi/vite.config.ts @@ -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()], +}); diff --git a/docs/api-v2-reference/openapi.yml b/docs/api-v2-reference/openapi.yml index de25cd17e7..810585d4b4 100644 --- a/docs/api-v2-reference/openapi.yml +++ b/docs/api-v2-reference/openapi.yml @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6665ac667e..99f655c6a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: