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
Copy Markdown
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
Copy Markdown
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