mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(api): add drip campaign (#65148)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user