Skip to content
Merged
104 changes: 104 additions & 0 deletions __tests__/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,110 @@ describe('anonymous boot', () => {
});
});

describe('recruiter default theme', () => {
it('should return light theme for anonymous user with referrer=recruiter', async () => {
const res = await request(app.server)
.get(`${BASE_PATH}?referrer=recruiter`)
.set('User-Agent', TEST_UA)
.expect(200);
expect(res.body.settings.theme).toEqual('bright');
});

it('should return dark theme for anonymous user without referrer', async () => {
const res = await request(app.server)
.get(BASE_PATH)
.set('User-Agent', TEST_UA)
.expect(200);
expect(res.body.settings.theme).toEqual('darcula');
});

it('should return dark theme for unknown referrer values', async () => {
const res = await request(app.server)
.get(`${BASE_PATH}?referrer=unknown`)
.set('User-Agent', TEST_UA)
.expect(200);
expect(res.body.settings.theme).toEqual('darcula');
});

it('should persist theme in Redis and return it on subsequent visits', async () => {
// First visit with recruiter referrer
const first = await request(app.server)
.get(`${BASE_PATH}?referrer=recruiter`)
.set('User-Agent', TEST_UA)
.expect(200);
expect(first.body.settings.theme).toEqual('bright');

// Second visit without referrer should still return light theme
const second = await request(app.server)
.get(BASE_PATH)
.set('User-Agent', TEST_UA)
.set('Cookie', first.headers['set-cookie'])
.expect(200);
expect(second.body.settings.theme).toEqual('bright');
});

it('should not override stored theme with new referrer', async () => {
// First visit without referrer (dark theme)
const first = await request(app.server)
.get(BASE_PATH)
.set('User-Agent', TEST_UA)
.expect(200);
expect(first.body.settings.theme).toEqual('darcula');

// Second visit with recruiter referrer should still return dark theme
const second = await request(app.server)
.get(`${BASE_PATH}?referrer=recruiter`)
.set('User-Agent', TEST_UA)
.set('Cookie', first.headers['set-cookie'])
.expect(200);
expect(second.body.settings.theme).toEqual('darcula');
});

it('should store theme in Redis with correct key pattern', async () => {
const res = await request(app.server)
.get(`${BASE_PATH}?referrer=recruiter`)
.set('User-Agent', TEST_UA)
.expect(200);

const trackingId = res.body.user.id;
const themeKey = generateStorageKey(StorageTopic.Boot, 'theme', trackingId);
const storedTheme = await getRedisObject(themeKey);
expect(storedTheme).toEqual('bright');
});

it('should persist theme after login', async () => {
const anon = await request(app.server)
.get(`${BASE_PATH}?referrer=recruiter`)
.set('User-Agent', TEST_UA)
.expect(200);
expect(anon.body.settings.theme).toEqual('bright');

// Simulate theme migration on login
const themeKey = generateStorageKey(StorageTopic.Boot, 'theme', '1');
await setRedisObject(themeKey, 'bright');

mockLoggedIn();
const loggedIn = await request(app.server)
.get(BASE_PATH)
.set('Cookie', 'ory_kratos_session=value;')
.expect(200);
expect(loggedIn.body.settings.theme).toEqual('bright');
});

it('should prefer DB settings over Redis theme', async () => {
const themeKey = generateStorageKey(StorageTopic.Boot, 'theme', '1');
await setRedisObject(themeKey, 'bright');
await con.getRepository(Settings).save({ userId: '1', theme: 'darcula' });

mockLoggedIn();
const res = await request(app.server)
.get(BASE_PATH)
.set('Cookie', 'ory_kratos_session=value;')
.expect(200);
expect(res.body.settings.theme).toEqual('darcula');
});
});

describe('logged in boot', () => {
it('should boot data when no access token cookie but whoami succeeds', async () => {
mockLoggedIn();
Expand Down
94 changes: 88 additions & 6 deletions src/routes/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,7 @@ const loggedInBoot = async ({
],
balance,
clickbaitTries,
anonymousTheme,
] = await Promise.all([
visitSection(req, res),
getRoles(userId),
Expand All @@ -687,6 +688,7 @@ const loggedInBoot = async ({
}),
getBalanceBoot({ userId }),
getClickbaitTries({ userId }),
getAnonymousTheme(userId),
]);

const profileCompletion = calculateProfileCompletion(user, experienceFlags);
Expand All @@ -695,6 +697,12 @@ const loggedInBoot = async ({
return handleNonExistentUser(con, req, res, middleware);
}

// Apply anonymous theme (e.g. recruiter light mode) if user has no saved settings
const finalSettings =
!settings.updatedAt && anonymousTheme
? { ...settings, theme: anonymousTheme }
: settings;

const hasLocationSet = !!user.flags?.location?.lastStored;
const isTeamMember = exp?.a?.team === 1;
const isPlus = isPlusMember(user.subscriptionFlags?.cycle);
Expand Down Expand Up @@ -781,7 +789,7 @@ const loggedInBoot = async ({
subDays(new Date(), FEED_SURVEY_INTERVAL) >
alerts.lastFeedSettingsFeedback,
},
settings: excludeProperties(settings, [
settings: excludeProperties(finalSettings, [
'userId',
'updatedAt',
'bookmarkSlug',
Expand Down Expand Up @@ -809,6 +817,46 @@ const getAnonymousFirstVisit = async (trackingId?: string) => {
return finalValue;
};

const ANONYMOUS_THEME_TTL = ONE_DAY_IN_SECONDS * 30; // 30 days, same as firstVisit

const getThemeRedisKey = (id: string): string =>
generateStorageKey(StorageTopic.Boot, 'theme', id);

/**
* Get stored theme preference from Redis for anonymous or authenticated users
*/
export const getAnonymousTheme = async (
id?: string,
): Promise<string | null> => {
if (!id) return null;
return getRedisObject(getThemeRedisKey(id));
};

/**
* Store theme preference in Redis for anonymous or authenticated users
*/
export const setAnonymousTheme = async (
id: string,
theme: string,
): Promise<void> => {
await setRedisObjectWithExpiry(
getThemeRedisKey(id),
theme,
ANONYMOUS_THEME_TTL,
);
};

/**
* Determine default theme based on referrer
* Recruiter-facing pages default to light mode
*/
const getDefaultThemeForReferrer = (referrer?: string): string => {
if (referrer === 'recruiter') {
return 'bright'; // light mode
}
return 'darcula'; // dark mode
};

// We released the firstVisit at July 10, 2023.
// There should have been enough buffer time since we are releasing on July 13, 2023.
export const onboardingV2Requirement = new Date(2023, 6, 13);
Expand All @@ -820,16 +868,24 @@ const anonymousBoot = async (
middleware?: BootMiddleware,
shouldVerify = false,
email?: string,
referrer?: string,
): Promise<AnonymousBoot> => {
const geo = geoSection(req);

const [visit, extra, firstVisit, exp] = await Promise.all([
const [visit, extra, firstVisit, exp, existingTheme] = await Promise.all([
visitSection(req, res),
middleware ? middleware(con, req, res) : {},
getAnonymousFirstVisit(req.trackingId),
getExperimentation({ userId: req.trackingId, con, ...geo }),
getAnonymousTheme(req.trackingId),
]);

// Determine theme: use existing preference or referrer-based default
const theme = existingTheme ?? getDefaultThemeForReferrer(referrer);
if (!existingTheme && req.trackingId) {
await setAnonymousTheme(req.trackingId, theme);
}

return {
user: {
firstVisit,
Expand All @@ -844,7 +900,10 @@ const anonymousBoot = async (
changelog: false,
shouldShowFeedFeedback: false,
},
settings: SETTINGS_DEFAULT,
settings: {
...SETTINGS_DEFAULT,
...(theme && { theme }),
},
notifications: { unreadNotificationsCount: 0 },
squads: [],
exp,
Expand All @@ -859,6 +918,9 @@ export const getBootData = async (
res: FastifyReply,
middleware?: BootMiddleware,
): Promise<AnonymousBoot | LoggedInBoot> => {
// Extract referrer from query params (e.g., ?referrer=recruiter)
const referrer = (req.query as { referrer?: string })?.referrer;

if (
req.userId &&
req.accessToken?.expiresIn &&
Expand All @@ -880,9 +942,25 @@ export const getBootData = async (
setRawCookie(res, whoami.cookie);
}
if (whoami.verified === false) {
return anonymousBoot(con, req, res, middleware, true, whoami?.email);
return anonymousBoot(
con,
req,
res,
middleware,
true,
whoami?.email,
referrer,
);
}
if (req.userId !== whoami.userId) {
// Migrate theme from anonymous trackingId to new userId before overwriting
const oldTrackingId = req.trackingId;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this part is needed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so right, because on signup you still don't have settings (until you do something) so we need to still read from redis until that's done.

if (oldTrackingId && oldTrackingId !== whoami.userId) {
const anonymousTheme = await getAnonymousTheme(oldTrackingId);
if (anonymousTheme) {
await setAnonymousTheme(whoami.userId, anonymousTheme);
}
}
req.userId = whoami.userId;
req.trackingId = req.userId;
setTrackingId(req, res, req.trackingId);
Expand All @@ -897,9 +975,9 @@ export const getBootData = async (
});
} else if (req.cookies[cookies.kratos.key]) {
await clearAuthentication(req, res, 'invalid cookie');
return anonymousBoot(con, req, res, middleware);
return anonymousBoot(con, req, res, middleware, false, undefined, referrer);
}
return anonymousBoot(con, req, res, middleware);
return anonymousBoot(con, req, res, middleware, false, undefined, referrer);
};

const COMPANION_QUERY = parse(`query Post($url: String) {
Expand Down Expand Up @@ -1149,6 +1227,7 @@ const funnelBoots = {
const funnelHandler: RouteHandler = async (req, res) => {
const con = await createOrGetConnection();
const { id = 'funnel' } = req.params as { id: keyof typeof funnelBoots };
const referrer = (req.query as { referrer?: string })?.referrer;

if (id in funnelBoots) {
const funnel = funnelBoots[id];
Expand All @@ -1157,6 +1236,9 @@ const funnelHandler: RouteHandler = async (req, res) => {
req,
res,
generateFunnelBootMiddle(funnel),
false,
undefined,
referrer,
)) as FunnelBoot;
return res.send(data);
}
Expand Down
Loading