From 67d7fa17ffa0e7f78acc88ee8b810861801a469a Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Tue, 20 Jan 2026 15:44:26 +0300 Subject: [PATCH] feat(api): add drip campaign (#65148) Co-authored-by: Oliver Eyton-Williams --- api/prisma/schema.prisma | 13 +++ api/src/plugins/auth-dev.test.ts | 11 ++- api/src/plugins/auth0.test.ts | 12 ++- api/src/plugins/growth-book.ts | 13 ++- api/src/routes/helpers/auth-helpers.test.ts | 94 +++++++++++++++++++++ api/src/routes/helpers/auth-helpers.ts | 43 ++++++++-- api/src/utils/drip-campaign.test.ts | 33 ++++++++ api/src/utils/drip-campaign.ts | 19 +++++ 8 files changed, 225 insertions(+), 13 deletions(-) create mode 100644 api/src/utils/drip-campaign.test.ts create mode 100644 api/src/utils/drip-campaign.ts diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 3bffec134a3..cd51e66c79f 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -337,3 +337,16 @@ type DailyCodingChallengeApiLanguageChallengeFiles { contents String fileKey String } + +// ---------------------- + +model DripCampaign { + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + creationDate DateTime @default(now()) @db.Date + email String + variant String + + @@index([userId], map: "userId_1") + @@index([email], map: "email_1") +} diff --git a/api/src/plugins/auth-dev.test.ts b/api/src/plugins/auth-dev.test.ts index 8cf8318ce9f..53d7000fc5e 100644 --- a/api/src/plugins/auth-dev.test.ts +++ b/api/src/plugins/auth-dev.test.ts @@ -9,11 +9,16 @@ import { import Fastify, { FastifyInstance } from 'fastify'; import { checkCanConnectToDb, defaultUserEmail } from '../../vitest.utils.js'; -import { HOME_LOCATION } from '../utils/env.js'; +import { + HOME_LOCATION, + GROWTHBOOK_FASTIFY_API_HOST, + GROWTHBOOK_FASTIFY_CLIENT_KEY +} from '../utils/env.js'; import { devAuth } from '../plugins/auth-dev.js'; import prismaPlugin from '../db/prisma.js'; import auth from './auth.js'; import cookies from './cookies.js'; +import growthBook from './growth-book.js'; import { newUser } from './__fixtures__/user.js'; @@ -28,6 +33,10 @@ describe('dev login', () => { await fastify.register(devAuth); await fastify.register(prismaPlugin); await checkCanConnectToDb(fastify.prisma); + await fastify.register(growthBook, { + apiHost: GROWTHBOOK_FASTIFY_API_HOST, + clientKey: GROWTHBOOK_FASTIFY_CLIENT_KEY + }); }); beforeEach(async () => { diff --git a/api/src/plugins/auth0.test.ts b/api/src/plugins/auth0.test.ts index 9a9406c0e11..755b2819f60 100644 --- a/api/src/plugins/auth0.test.ts +++ b/api/src/plugins/auth0.test.ts @@ -12,13 +12,19 @@ import { import Fastify, { FastifyInstance } from 'fastify'; import { createUserInput } from '../utils/create-user.js'; -import { AUTH0_DOMAIN, HOME_LOCATION } from '../utils/env.js'; +import { + AUTH0_DOMAIN, + HOME_LOCATION, + GROWTHBOOK_FASTIFY_API_HOST, + GROWTHBOOK_FASTIFY_CLIENT_KEY +} from '../utils/env.js'; import prismaPlugin from '../db/prisma.js'; import cookies, { sign, unsign } from './cookies.js'; import { auth0Client } from './auth0.js'; import redirectWithMessage, { formatMessage } from './redirect-with-message.js'; import auth from './auth.js'; import bouncer from './bouncer.js'; +import growthBook from './growth-book.js'; import { newUser } from './__fixtures__/user.js'; const COOKIE_DOMAIN = 'test.com'; @@ -40,6 +46,10 @@ describe('auth0 plugin', () => { await fastify.register(bouncer); await fastify.register(auth0Client); await fastify.register(prismaPlugin); + await fastify.register(growthBook, { + apiHost: GROWTHBOOK_FASTIFY_API_HOST, + clientKey: GROWTHBOOK_FASTIFY_CLIENT_KEY + }); }); describe('GET /signin/google', () => { diff --git a/api/src/plugins/growth-book.ts b/api/src/plugins/growth-book.ts index 9b6a77a8534..3c2e5b93c87 100644 --- a/api/src/plugins/growth-book.ts +++ b/api/src/plugins/growth-book.ts @@ -12,11 +12,16 @@ declare module 'fastify' { const growthBook: FastifyPluginAsync = async (fastify, options) => { const gb = new GrowthBook(options); - const res = await gb.init({ timeout: 3000 }); - if (res.error && FREECODECAMP_NODE_ENV === 'production') { - fastify.log.error(res.error, 'Failed to initialize GrowthBook'); - fastify.Sentry.captureException(res.error); + const hasRequiredConfig = Boolean(options.clientKey && options.apiHost); + + if (hasRequiredConfig) { + const res = await gb.init({ timeout: 3000 }); + + if (res.error && FREECODECAMP_NODE_ENV === 'production') { + fastify.log.error(res.error, 'Failed to initialize GrowthBook'); + fastify.Sentry.captureException(res.error); + } } fastify.decorate('gb', gb); diff --git a/api/src/routes/helpers/auth-helpers.test.ts b/api/src/routes/helpers/auth-helpers.test.ts index d390addc681..d097a2e66c8 100644 --- a/api/src/routes/helpers/auth-helpers.test.ts +++ b/api/src/routes/helpers/auth-helpers.test.ts @@ -13,6 +13,12 @@ import db from '../../db/prisma.js'; import { createUserInput } from '../../utils/create-user.js'; import { checkCanConnectToDb } from '../../../vitest.utils.js'; import { findOrCreateUser } from './auth-helpers.js'; +import { assignVariantBucket } from '../../utils/drip-campaign.js'; +import growthBook from '../../plugins/growth-book.js'; +import { + GROWTHBOOK_FASTIFY_API_HOST, + GROWTHBOOK_FASTIFY_CLIENT_KEY +} from '../../utils/env.js'; const captureException = vi.fn(); @@ -22,6 +28,10 @@ async function setupServer() { await checkCanConnectToDb(fastify.prisma); // @ts-expect-error we're mocking the Sentry plugin fastify.Sentry = { captureException }; + await fastify.register(growthBook, { + apiHost: GROWTHBOOK_FASTIFY_API_HOST, + clientKey: GROWTHBOOK_FASTIFY_CLIENT_KEY + }); return fastify; } @@ -39,6 +49,7 @@ describe('findOrCreateUser', () => { afterEach(async () => { await fastify.prisma.user.deleteMany({ where: { email } }); + await fastify.prisma.dripCampaign.deleteMany({ where: { email } }); await fastify.close(); vi.clearAllMocks(); }); @@ -74,4 +85,87 @@ describe('findOrCreateUser', () => { expect(captureException).not.toHaveBeenCalled(); }); + + describe('drip campaign logic', () => { + test('should create a drip campaign record when a new user is created and feature flag is enabled', async () => { + vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true); + + const user = await findOrCreateUser(fastify, email); + + const dripCampaign = await fastify.prisma.dripCampaign.findFirst({ + where: { userId: user.id } + }); + + expect(dripCampaign).toBeDefined(); + expect(dripCampaign?.userId).toBe(user.id); + expect(dripCampaign?.email).toBe(email); + expect(['A', 'B']).toContain(dripCampaign?.variant); + }); + + test('should assign a consistent variant based on userId', async () => { + vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true); + + const user = await findOrCreateUser(fastify, email); + const expectedVariant = assignVariantBucket(user.id); + + const dripCampaign = await fastify.prisma.dripCampaign.findFirst({ + where: { userId: user.id } + }); + + expect(dripCampaign?.variant).toBe(expectedVariant); + }); + + test('should not create a drip campaign record when feature flag is disabled', async () => { + vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => false); + + const user = await findOrCreateUser(fastify, email); + + const dripCampaign = await fastify.prisma.dripCampaign.findFirst({ + where: { userId: user.id } + }); + + expect(dripCampaign).toBeNull(); + }); + + test('should not prevent user creation if drip campaign record creation fails', async () => { + vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true); + + // Mock dripCampaign.create to throw an error + const createSpy = vi + .spyOn(fastify.prisma.dripCampaign, 'create') + .mockRejectedValueOnce(new Error('Database error')); + + const user = await findOrCreateUser(fastify, email); + + expect(user).toBeDefined(); + expect(user.id).toBeTruthy(); + + // Verify error was captured by Sentry + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Database error' + }) + ); + + createSpy.mockRestore(); + }); + + test('should not create drip campaign for existing users', async () => { + vi.spyOn(fastify.gb, 'isOn').mockImplementationOnce(() => true); + + // Create user first + await fastify.prisma.user.create({ data: createUserInput(email) }); + + // Call findOrCreateUser for existing user + await findOrCreateUser(fastify, email); + + // Verify no drip campaign record was created + const dripCampaigns = await fastify.prisma.dripCampaign.findMany({ + where: { email } + }); + + expect(dripCampaigns).toHaveLength(0); + }); + }); }); diff --git a/api/src/routes/helpers/auth-helpers.ts b/api/src/routes/helpers/auth-helpers.ts index f8b3b62853a..14af3da754a 100644 --- a/api/src/routes/helpers/auth-helpers.ts +++ b/api/src/routes/helpers/auth-helpers.ts @@ -1,5 +1,6 @@ import { FastifyInstance } from 'fastify'; import { createUserInput } from '../../utils/create-user.js'; +import { assignVariantBucket } from '../../utils/drip-campaign.js'; /** * Finds an existing user with the given email or creates a new user if none exists. @@ -25,11 +26,39 @@ export const findOrCreateUser = async ( ); } - return ( - existingUser[0] ?? - (await fastify.prisma.user.create({ - data: createUserInput(email), - select: { id: true, acceptedPrivacyTerms: true } - })) - ); + if (existingUser[0]) { + return existingUser[0]; + } + + // Create new user + const newUser = await fastify.prisma.user.create({ + data: createUserInput(email), + select: { id: true, acceptedPrivacyTerms: true } + }); + + // Create drip campaign record if feature flag is enabled + if (fastify.gb.isOn('drip-campaign')) { + try { + const variant = assignVariantBucket(newUser.id); + await fastify.prisma.dripCampaign.create({ + data: { + userId: newUser.id, + email, + variant + } + }); + fastify.log.info( + `Drip campaign record created for user ${newUser.id} with variant ${variant}` + ); + } catch (error) { + // Log the error but don't fail user creation + fastify.log.error( + error, + `Failed to create drip campaign record for user ${newUser.id}` + ); + fastify.Sentry.captureException(error); + } + } + + return newUser; }; diff --git a/api/src/utils/drip-campaign.test.ts b/api/src/utils/drip-campaign.test.ts new file mode 100644 index 00000000000..2058e31c6cc --- /dev/null +++ b/api/src/utils/drip-campaign.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { assignVariantBucket } from './drip-campaign.js'; + +describe('assignVariantBucket', () => { + it('should return either A or B', () => { + const variant = assignVariantBucket('test-user-id'); + expect(['A', 'B']).toContain(variant); + }); + + it('should return consistent results for the same userId', () => { + const userId = '6863cb33ad61b38a74d2ba40'; + const variant1 = assignVariantBucket(userId); + const variant2 = assignVariantBucket(userId); + const variant3 = assignVariantBucket(userId); + + expect(variant1).toBe(variant2); + expect(variant2).toBe(variant3); + }); + + it('should distribute users across both buckets', () => { + const variants = new Set(); + + // Test with multiple user IDs to ensure both buckets are possible + for (let i = 0; i < 100; i++) { + const variant = assignVariantBucket(`user-${i}`); + variants.add(variant); + } + + // Both A and B should be present + expect(variants.has('A')).toBe(true); + expect(variants.has('B')).toBe(true); + }); +}); diff --git a/api/src/utils/drip-campaign.ts b/api/src/utils/drip-campaign.ts new file mode 100644 index 00000000000..1c676e513eb --- /dev/null +++ b/api/src/utils/drip-campaign.ts @@ -0,0 +1,19 @@ +import crypto from 'node:crypto'; + +/** + * Assigns a user to variant bucket A or B based on a hash of their userId. + * This ensures consistent variant assignment for the same userId. + * + * @param userId - The user's unique identifier. + * @returns 'A' or 'B' based on the hash. + */ +export function assignVariantBucket(userId: string): 'A' | 'B' { + // Create a hash of the userId + const hash = crypto.createHash('sha256').update(userId).digest('hex'); + + // Convert first character of hash to a number (0-15 in hex) + // Use modulo 2 to determine bucket A or B + const numericValue = parseInt(hash.charAt(0), 16); + + return numericValue % 2 === 0 ? 'A' : 'B'; +}