diff --git a/.env.example b/.env.example index 2a8ba5eebb..1d3dd9e019 100644 --- a/.env.example +++ b/.env.example @@ -167,9 +167,9 @@ ENTERPRISE_LICENSE_KEY= # DEFAULT_ORGANIZATION_ID= # DEFAULT_ORGANIZATION_ROLE=owner -# Send new users to customer.io -# CUSTOMER_IO_API_KEY= -# CUSTOMER_IO_SITE_ID= +# Send new users to Brevo +# BREVO_API_KEY= +# BREVO_LIST_ID= # Ignore Rate Limiting across the Formbricks app # RATE_LIMITING_DISABLED=1 diff --git a/apps/web/modules/auth/lib/authOptions.ts b/apps/web/modules/auth/lib/authOptions.ts index a12f9aecae..73a09f3e46 100644 --- a/apps/web/modules/auth/lib/authOptions.ts +++ b/apps/web/modules/auth/lib/authOptions.ts @@ -13,6 +13,7 @@ import { import { symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto"; import { verifyToken } from "@formbricks/lib/jwt"; import { TUser } from "@formbricks/types/user"; +import { createBrevoCustomer } from "./brevo"; export const authOptions: NextAuthOptions = { providers: [ @@ -162,6 +163,9 @@ export const authOptions: NextAuthOptions = { user = await updateUser(user.id, { emailVerified: new Date() }); + // send new user to brevo after email verification + createBrevoCustomer({ id: user.id, email: user.email }); + return user; }, }), diff --git a/apps/web/modules/auth/lib/brevo.test.ts b/apps/web/modules/auth/lib/brevo.test.ts new file mode 100644 index 0000000000..e789485673 --- /dev/null +++ b/apps/web/modules/auth/lib/brevo.test.ts @@ -0,0 +1,58 @@ +import { Response } from "node-fetch"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { createBrevoCustomer } from "./brevo"; + +vi.mock("@formbricks/lib/constants", () => ({ + BREVO_API_KEY: "mock_api_key", + BREVO_LIST_ID: "123", +})); + +vi.mock("@formbricks/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); + +global.fetch = vi.fn(); + +describe("createBrevoCustomer", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return early if BREVO_API_KEY is not defined", async () => { + vi.doMock("@formbricks/lib/constants", () => ({ + BREVO_API_KEY: undefined, + BREVO_LIST_ID: "123", + })); + + const { createBrevoCustomer } = await import("./brevo"); + + const result = await createBrevoCustomer({ id: "123", email: "test@example.com" }); + + expect(result).toBeUndefined(); + expect(global.fetch).not.toHaveBeenCalled(); + expect(validateInputs).not.toHaveBeenCalled(); + }); + + it("should log an error if fetch fails", async () => { + const consoleSpy = vi.spyOn(console, "error"); + + vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Fetch failed")); + + await createBrevoCustomer({ id: "123", email: "test@example.com" }); + + expect(consoleSpy).toHaveBeenCalledWith("Error sending user to Brevo:", expect.any(Error)); + }); + + it("should log the error response if fetch status is not 200", async () => { + const consoleSpy = vi.spyOn(console, "error"); + + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response("Bad Request", { status: 400, statusText: "Bad Request" }) + ); + + await createBrevoCustomer({ id: "123", email: "test@example.com" }); + + expect(consoleSpy).toHaveBeenCalledWith("Error sending user to Brevo:", "Bad Request"); + }); +}); diff --git a/apps/web/modules/auth/lib/brevo.ts b/apps/web/modules/auth/lib/brevo.ts new file mode 100644 index 0000000000..4308f4857b --- /dev/null +++ b/apps/web/modules/auth/lib/brevo.ts @@ -0,0 +1,42 @@ +import { BREVO_API_KEY, BREVO_LIST_ID } from "@formbricks/lib/constants"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { ZId } from "@formbricks/types/common"; +import { TUserEmail, ZUserEmail } from "@formbricks/types/user"; + +export const createBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => { + if (!BREVO_API_KEY) { + return; + } + + validateInputs([id, ZId], [email, ZUserEmail]); + + try { + const requestBody: any = { + email, + ext_id: id, + updateEnabled: false, + }; + + // Add `listIds` only if `BREVO_LIST_ID` is defined + const listId = BREVO_LIST_ID ? parseInt(BREVO_LIST_ID, 10) : null; + if (listId && !Number.isNaN(listId)) { + requestBody.listIds = [listId]; + } + + const res = await fetch("https://api.brevo.com/v3/contacts", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "api-key": BREVO_API_KEY, + }, + body: JSON.stringify(requestBody), + }); + + if (res.status !== 200) { + console.error("Error sending user to Brevo:", await res.text()); + } + } catch (error) { + console.error("Error sending user to Brevo:", error); + } +}; diff --git a/apps/web/modules/auth/lib/user.test.ts b/apps/web/modules/auth/lib/user.test.ts index ee5ef11ce0..1cbbd63dc8 100644 --- a/apps/web/modules/auth/lib/user.test.ts +++ b/apps/web/modules/auth/lib/user.test.ts @@ -1,7 +1,6 @@ import { Prisma } from "@prisma/client"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { createCustomerIoCustomer } from "@formbricks/lib/customerio"; import { userCache } from "@formbricks/lib/user/cache"; import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { mockUser } from "./mock-data"; @@ -27,10 +26,6 @@ vi.mock("@formbricks/database", () => ({ }, })); -vi.mock("@formbricks/lib/customerio", () => ({ - createCustomerIoCustomer: vi.fn(), -})); - vi.mock("@formbricks/lib/user/cache", () => ({ userCache: { revalidate: vi.fn(), @@ -57,10 +52,6 @@ describe("User Management", () => { }); expect(result).toEqual(mockPrismaUser); - expect(createCustomerIoCustomer).toHaveBeenCalledWith({ - id: mockPrismaUser.id, - email: mockPrismaUser.email, - }); expect(userCache.revalidate).toHaveBeenCalled(); }); diff --git a/apps/web/modules/auth/lib/user.ts b/apps/web/modules/auth/lib/user.ts index e156a6ef28..ab47f40e6e 100644 --- a/apps/web/modules/auth/lib/user.ts +++ b/apps/web/modules/auth/lib/user.ts @@ -2,7 +2,6 @@ import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; import { cache } from "@formbricks/lib/cache"; -import { createCustomerIoCustomer } from "@formbricks/lib/customerio"; import { userCache } from "@formbricks/lib/user/cache"; import { validateInputs } from "@formbricks/lib/utils/validate"; import { ZId } from "@formbricks/types/common"; @@ -128,8 +127,6 @@ export const createUser = async (data: TUserCreateInput) => { count: true, }); - // send new user customer.io to customer.io - createCustomerIoCustomer({ id: user.id, email: user.email }); return user; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { diff --git a/apps/web/modules/ee/sso/lib/sso-handlers.ts b/apps/web/modules/ee/sso/lib/sso-handlers.ts index ef44bf599c..be6433fd44 100644 --- a/apps/web/modules/ee/sso/lib/sso-handlers.ts +++ b/apps/web/modules/ee/sso/lib/sso-handlers.ts @@ -1,3 +1,4 @@ +import { createBrevoCustomer } from "@/modules/auth/lib/brevo"; import { getUserByEmail, updateUser } from "@/modules/auth/lib/user"; import { createUser } from "@/modules/auth/lib/user"; import type { IdentityProvider } from "@prisma/client"; @@ -78,6 +79,9 @@ export const handleSSOCallback = async ({ user, account }: { user: TUser; accoun locale: await findMatchingLocale(), }); + // send new user to brevo + createBrevoCustomer({ id: user.id, email: user.email }); + // Default organization assignment if env variable is set if (DEFAULT_ORGANIZATION_ID && DEFAULT_ORGANIZATION_ID.length > 0) { // check if organization exists diff --git a/apps/web/package.json b/apps/web/package.json index 91ca49058e..83d44b170b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -92,6 +92,7 @@ "next-auth": "4.24.11", "next-intl": "3.26.1", "next-safe-action": "7.10.2", + "node-fetch": "3.3.2", "nodemailer": "6.9.16", "optional": "0.1.4", "otplib": "12.0.1", diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 8b1ca0e9e0..24c7dc7905 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -188,8 +188,9 @@ export const REDIS_URL = env.REDIS_URL; export const REDIS_HTTP_URL = env.REDIS_HTTP_URL; export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1"; -export const CUSTOMER_IO_SITE_ID = env.CUSTOMER_IO_SITE_ID; -export const CUSTOMER_IO_API_KEY = env.CUSTOMER_IO_API_KEY; +export const BREVO_API_KEY = env.BREVO_API_KEY; +export const BREVO_LIST_ID = env.BREVO_LIST_ID; + export const UNSPLASH_ACCESS_KEY = env.UNSPLASH_ACCESS_KEY; export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"]; diff --git a/packages/lib/customerio.ts b/packages/lib/customerio.ts deleted file mode 100644 index 9c7f5f5f8b..0000000000 --- a/packages/lib/customerio.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ZId } from "@formbricks/types/common"; -import { TUserEmail, ZUserEmail } from "@formbricks/types/user"; -import { CUSTOMER_IO_API_KEY, CUSTOMER_IO_SITE_ID } from "./constants"; -import { validateInputs } from "./utils/validate"; - -export const createCustomerIoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => { - if (!CUSTOMER_IO_SITE_ID || !CUSTOMER_IO_API_KEY) { - return; - } - - validateInputs([id, ZId], [email, ZUserEmail]); - - try { - const auth = Buffer.from(`${CUSTOMER_IO_SITE_ID}:${CUSTOMER_IO_API_KEY}`).toString("base64"); - const res = await fetch(`https://track-eu.customer.io/api/v1/customers/${id}`, { - method: "PUT", - headers: { - Authorization: `Basic ${auth}`, - }, - body: JSON.stringify({ - id: id, - email: email, - }), - }); - if (res.status !== 200) { - console.log("Error sending user to CustomerIO:", await res.text()); - } - } catch (error) { - console.log("error sending user to CustomerIO:", error); - } -}; diff --git a/packages/lib/env.ts b/packages/lib/env.ts index 879b5d5093..66e421f1ee 100644 --- a/packages/lib/env.ts +++ b/packages/lib/env.ts @@ -18,8 +18,8 @@ export const env = createEnv({ AZUREAD_CLIENT_SECRET: z.string().optional(), AZUREAD_TENANT_ID: z.string().optional(), CRON_SECRET: z.string().min(10), - CUSTOMER_IO_API_KEY: z.string().optional(), - CUSTOMER_IO_SITE_ID: z.string().optional(), + BREVO_API_KEY: z.string().optional(), + BREVO_LIST_ID: z.string().optional(), DATABASE_URL: z.string().url(), DEBUG: z.enum(["1", "0"]).optional(), DEFAULT_ORGANIZATION_ID: z.string().optional(), @@ -141,9 +141,9 @@ export const env = createEnv({ AZUREAD_CLIENT_ID: process.env.AZUREAD_CLIENT_ID, AZUREAD_CLIENT_SECRET: process.env.AZUREAD_CLIENT_SECRET, AZUREAD_TENANT_ID: process.env.AZUREAD_TENANT_ID, + BREVO_API_KEY: process.env.BREVO_API_KEY, + BREVO_LIST_ID: process.env.BREVO_LIST_ID, CRON_SECRET: process.env.CRON_SECRET, - CUSTOMER_IO_API_KEY: process.env.CUSTOMER_IO_API_KEY, - CUSTOMER_IO_SITE_ID: process.env.CUSTOMER_IO_SITE_ID, DATABASE_URL: process.env.DATABASE_URL, DEBUG: process.env.DEBUG, DEFAULT_ORGANIZATION_ID: process.env.DEFAULT_ORGANIZATION_ID, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 916ab22514..f95ed8af66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,7 +154,7 @@ importers: version: 8.14.0 autoprefixer: specifier: 10.4.20 - version: 10.4.20(postcss@8.4.49) + version: 10.4.20(postcss@8.5.1) clsx: specifier: 2.1.1 version: 2.1.1 @@ -513,7 +513,7 @@ importers: version: 4.0.18(react@19.0.0)(zod@3.24.1) autoprefixer: specifier: 10.4.20 - version: 10.4.20(postcss@8.5.1) + version: 10.4.20(postcss@8.4.49) bcryptjs: specifier: 2.4.3 version: 2.4.3 @@ -586,6 +586,9 @@ importers: next-safe-action: specifier: 7.10.2 version: 7.10.2(next@15.1.2(@opentelemetry/api@1.9.0)(@playwright/test@1.49.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(zod@3.24.1) + node-fetch: + specifier: 3.3.2 + version: 3.3.2 nodemailer: specifier: 6.9.16 version: 6.9.16 diff --git a/turbo.json b/turbo.json index 6a752b0aff..32d10489ce 100644 --- a/turbo.json +++ b/turbo.json @@ -84,12 +84,12 @@ "AZUREAD_CLIENT_ID", "AZUREAD_CLIENT_SECRET", "AZUREAD_TENANT_ID", + "BREVO_API_KEY", + "BREVO_LIST_ID", "DEFAULT_ORGANIZATION_ID", "DEFAULT_ORGANIZATION_ROLE", "CRON_SECRET", "CUSTOM_CACHE_DISABLED", - "CUSTOMER_IO_API_KEY", - "CUSTOMER_IO_SITE_ID", "DATABASE_URL", "DEBUG", "E2E_TESTING",