feat(api): add drip campaign (#65148)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2026-01-20 15:44:26 +03:00
committed by GitHub
parent 214c5a3240
commit 67d7fa17ff
8 changed files with 225 additions and 13 deletions
+13
View File
@@ -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")
}
+10 -1
View File
@@ -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 () => {
+11 -1
View File
@@ -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', () => {
+9 -4
View File
@@ -12,11 +12,16 @@ declare module 'fastify' {
const growthBook: FastifyPluginAsync<Options> = 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);
@@ -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);
});
});
});
+36 -7
View File
@@ -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;
};
+33
View File
@@ -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<string>();
// 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);
});
});
+19
View File
@@ -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';
}