From dea6621164f685d998eeddcd74322036a8162b4c Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 13 Jan 2026 15:24:28 +0200 Subject: [PATCH 1/5] fix: anon user --- src/common/schema/opportunityMatch.ts | 7 ++++ src/schema/opportunity.ts | 50 +++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/common/schema/opportunityMatch.ts b/src/common/schema/opportunityMatch.ts index 47030e8dad..de268c63d5 100644 --- a/src/common/schema/opportunityMatch.ts +++ b/src/common/schema/opportunityMatch.ts @@ -18,6 +18,13 @@ export type FeedbackClassification = z.infer< typeof feedbackClassificationSchema >; +export const anonymousUserContextSchema = z.object({ + seniority: z.string().nullable().optional(), + locationCountry: z.string().nullable().optional(), +}); + +export type AnonymousUserContext = z.infer; + export const opportunityScreeningAnswersSchema = z.object({ id: z.uuid(), answers: z diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 1bb5aab217..4392200df6 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -372,6 +372,20 @@ export const typeDefs = /* GraphQL */ ` edges: [OpportunityMatchEdge!]! } + """ + Anonymous user context data captured at feedback submission time + """ + type AnonymousUserContext { + """ + User's seniority/experience level (e.g., junior, senior, staff) + """ + seniority: String + """ + User's country (general location, not specific city) + """ + locationCountry: String + } + type FeedbackClassification { platform: Int! category: Int! @@ -379,6 +393,10 @@ export const typeDefs = /* GraphQL */ ` urgency: Int! screening: String! answer: String! + """ + Anonymous user context captured at feedback submission + """ + userContext: AnonymousUserContext } type FeedbackClassificationEdge { @@ -1830,23 +1848,49 @@ export const resolvers: IResolvers = traceResolvers< where: { opportunityId, }, - select: ['feedback'], + select: ['feedback', 'userId'], }), ); + // Gather unique userIds and fetch their anonymous context + const userIds = [...new Set(matches.map((m) => m.userId))]; + const userContextMap = new Map< + string, + { seniority: string | null; locationCountry: string | null } + >(); + + const users = await ctx.con.getRepository(User).find({ + where: { id: In(userIds) }, + select: ['id', 'experienceLevel', 'flags'], + }); + + for (const user of users) { + const flags = (user.flags ?? {}) as Record; + userContextMap.set(user.id, { + seniority: user.experienceLevel ?? null, + locationCountry: (flags.country as string) ?? null, + }); + } + // Extract feedback items with recruiter platform classification const allFeedback = matches - .flatMap((match) => match.feedback ?? []) + .flatMap((match) => + (match.feedback ?? []).map((f) => ({ + ...f, + userId: match.userId, + })), + ) .filter( (f) => f.classification?.platform === FeedbackPlatform.RECRUITER, ) - .map(({ screening, answer, classification }) => ({ + .map(({ screening, answer, classification, userId }) => ({ screening, answer, platform: classification!.platform, category: classification!.category, sentiment: classification!.sentiment, urgency: classification!.urgency, + userContext: userContextMap.get(userId) ?? {}, })); const totalCount = allFeedback.length; From d86d1d1644ffb30fc5affcee73b6906648f5b0b9 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:58:59 +0000 Subject: [PATCH 2/5] fix: prioritize candidatePreference location country over flags.country - Fetch UserCandidatePreference with location relation - Use preferenceLocation.country as primary source, fallback to flags.country - Ensures user's explicit location preference takes precedence Co-authored-by: Chris Bongers --- src/schema/opportunity.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 4392200df6..4ed9d5d6ff 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -1864,11 +1864,31 @@ export const resolvers: IResolvers = traceResolvers< select: ['id', 'experienceLevel', 'flags'], }); + // Fetch candidatePreferences with location relation + const candidatePreferences = await ctx.con + .getRepository(UserCandidatePreference) + .find({ + where: { userId: In(userIds) }, + relations: ['location'], + select: ['userId'], + }); + + const preferenceMap = new Map( + candidatePreferences.map((pref) => [pref.userId, pref]), + ); + for (const user of users) { const flags = (user.flags ?? {}) as Record; + const preference = preferenceMap.get(user.id); + const preferenceLocation = preference?.location + ? await preference.location + : null; + userContextMap.set(user.id, { seniority: user.experienceLevel ?? null, - locationCountry: (flags.country as string) ?? null, + // Prioritize candidatePreference location country over flags.country + locationCountry: + preferenceLocation?.country ?? (flags.country as string) ?? null, }); } From 2d0f636e21605eafa37d8c2ccc7d2e27846f0593 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Wed, 14 Jan 2026 13:23:36 +0200 Subject: [PATCH 3/5] fix: uniform renders --- src/schema/opportunity.ts | 55 +++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 4ed9d5d6ff..85dd05303f 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -1852,46 +1852,39 @@ export const resolvers: IResolvers = traceResolvers< }), ); - // Gather unique userIds and fetch their anonymous context + // Gather unique userIds and fetch their anonymous context with candidate preferences const userIds = [...new Set(matches.map((m) => m.userId))]; - const userContextMap = new Map< - string, - { seniority: string | null; locationCountry: string | null } - >(); const users = await ctx.con.getRepository(User).find({ where: { id: In(userIds) }, select: ['id', 'experienceLevel', 'flags'], + relations: ['candidatePreference', 'candidatePreference.location'], }); - // Fetch candidatePreferences with location relation - const candidatePreferences = await ctx.con - .getRepository(UserCandidatePreference) - .find({ - where: { userId: In(userIds) }, - relations: ['location'], - select: ['userId'], - }); - - const preferenceMap = new Map( - candidatePreferences.map((pref) => [pref.userId, pref]), + const userContextMap = new Map( + await Promise.all( + users.map(async (user) => { + const flags = (user.flags ?? {}) as Record; + const preference = await user.candidatePreference; + const preferenceLocation = preference?.location + ? await preference.location + : null; + + return [ + user.id, + { + seniority: user.experienceLevel ?? null, + // Prioritize candidatePreference location country over flags.country + locationCountry: + preferenceLocation?.country ?? + (flags.country as string) ?? + null, + }, + ] as const; + }), + ), ); - for (const user of users) { - const flags = (user.flags ?? {}) as Record; - const preference = preferenceMap.get(user.id); - const preferenceLocation = preference?.location - ? await preference.location - : null; - - userContextMap.set(user.id, { - seniority: user.experienceLevel ?? null, - // Prioritize candidatePreference location country over flags.country - locationCountry: - preferenceLocation?.country ?? (flags.country as string) ?? null, - }); - } - // Extract feedback items with recruiter platform classification const allFeedback = matches .flatMap((match) => From 1238abd2f24957b19421a14753d2b89d3412d9a0 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Wed, 14 Jan 2026 15:47:41 +0200 Subject: [PATCH 4/5] feat: default light theme for recruiter-entry users - Add Redis storage for anonymous user theme preferences (30-day TTL) - Accept `referrer` query param in boot endpoint - Return light theme ('bright') for referrer=recruiter, dark otherwise - Persist theme choice and don't override on subsequent visits - Migrate theme from anonymous trackingId to userId on login - Add tests for recruiter default theme logic Closes ENG-332, ENG-334, ENG-335, ENG-336 Co-Authored-By: Claude Opus 4.5 --- __tests__/boot.ts | 72 ++++++++++++++++++++++++++++++++++++++ src/routes/boot.ts | 86 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/__tests__/boot.ts b/__tests__/boot.ts index 60e2850875..b68521c071 100644 --- a/__tests__/boot.ts +++ b/__tests__/boot.ts @@ -377,6 +377,78 @@ 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'); + }); +}); + describe('logged in boot', () => { it('should boot data when no access token cookie but whoami succeeds', async () => { mockLoggedIn(); diff --git a/src/routes/boot.ts b/src/routes/boot.ts index e0977c0da5..0ddd1e4c36 100644 --- a/src/routes/boot.ts +++ b/src/routes/boot.ts @@ -809,6 +809,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 => { + 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 => { + 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); @@ -820,16 +860,26 @@ const anonymousBoot = async ( middleware?: BootMiddleware, shouldVerify = false, email?: string, + referrer?: string, ): Promise => { 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 + let theme = existingTheme; + if (!theme && req.trackingId) { + theme = getDefaultThemeForReferrer(referrer); + // Store the default theme for future visits + await setAnonymousTheme(req.trackingId, theme); + } + return { user: { firstVisit, @@ -844,7 +894,10 @@ const anonymousBoot = async ( changelog: false, shouldShowFeedFeedback: false, }, - settings: SETTINGS_DEFAULT, + settings: { + ...SETTINGS_DEFAULT, + ...(theme && { theme }), + }, notifications: { unreadNotificationsCount: 0 }, squads: [], exp, @@ -859,6 +912,9 @@ export const getBootData = async ( res: FastifyReply, middleware?: BootMiddleware, ): Promise => { + // Extract referrer from query params (e.g., ?referrer=recruiter) + const referrer = (req.query as { referrer?: string })?.referrer; + if ( req.userId && req.accessToken?.expiresIn && @@ -880,9 +936,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; + 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); @@ -897,9 +969,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) { @@ -1149,6 +1221,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]; @@ -1157,6 +1230,9 @@ const funnelHandler: RouteHandler = async (req, res) => { req, res, generateFunnelBootMiddle(funnel), + false, + undefined, + referrer, )) as FunnelBoot; return res.send(data); } From c149add1699388053b28b1d644d0d9bd905e8d8f Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 15 Jan 2026 09:35:51 +0200 Subject: [PATCH 5/5] fix: persist recruiter light theme after signup + add tests - Apply anonymous theme from Redis when user has no saved settings - Add tests for theme persistence after login - Add test for DB settings taking precedence over Redis - Simplify theme determination logic Co-Authored-By: Claude Opus 4.5 --- __tests__/boot.ts | 32 ++++++++++++++++++++++++++++++++ src/routes/boot.ts | 16 +++++++++++----- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/__tests__/boot.ts b/__tests__/boot.ts index b68521c071..a2e29dfcb2 100644 --- a/__tests__/boot.ts +++ b/__tests__/boot.ts @@ -447,6 +447,38 @@ describe('recruiter default theme', () => { 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', () => { diff --git a/src/routes/boot.ts b/src/routes/boot.ts index 0ddd1e4c36..94d05c662a 100644 --- a/src/routes/boot.ts +++ b/src/routes/boot.ts @@ -662,6 +662,7 @@ const loggedInBoot = async ({ ], balance, clickbaitTries, + anonymousTheme, ] = await Promise.all([ visitSection(req, res), getRoles(userId), @@ -687,6 +688,7 @@ const loggedInBoot = async ({ }), getBalanceBoot({ userId }), getClickbaitTries({ userId }), + getAnonymousTheme(userId), ]); const profileCompletion = calculateProfileCompletion(user, experienceFlags); @@ -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); @@ -781,7 +789,7 @@ const loggedInBoot = async ({ subDays(new Date(), FEED_SURVEY_INTERVAL) > alerts.lastFeedSettingsFeedback, }, - settings: excludeProperties(settings, [ + settings: excludeProperties(finalSettings, [ 'userId', 'updatedAt', 'bookmarkSlug', @@ -873,10 +881,8 @@ const anonymousBoot = async ( ]); // Determine theme: use existing preference or referrer-based default - let theme = existingTheme; - if (!theme && req.trackingId) { - theme = getDefaultThemeForReferrer(referrer); - // Store the default theme for future visits + const theme = existingTheme ?? getDefaultThemeForReferrer(referrer); + if (!existingTheme && req.trackingId) { await setAnonymousTheme(req.trackingId, theme); }