chore(cloud): move from customer-io to brevo (#4681)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
This commit is contained in:
Matti Nannt
2025-01-29 10:18:16 +01:00
committed by GitHub
parent 8e116bf62d
commit 458f135ee1
13 changed files with 126 additions and 56 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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") {

View File

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

View File

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

View File

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

View File

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

View File

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

7
pnpm-lock.yaml generated
View File

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

View File

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