Skip to content

Commit 5956454

Browse files
authored
feat: add profile completion calculation to boot (#3373)
1 parent 9dc8029 commit 5956454

2 files changed

Lines changed: 217 additions & 0 deletions

File tree

__tests__/boot.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ import {
8888
ContentPreferenceOrganization,
8989
ContentPreferenceOrganizationStatus,
9090
} from '../src/entity/contentPreference/ContentPreferenceOrganization';
91+
import { UserExperienceWork } from '../src/entity/user/experiences/UserExperienceWork';
92+
import { UserExperienceEducation } from '../src/entity/user/experiences/UserExperienceEducation';
9193

9294
let app: FastifyInstance;
9395
let con: DataSource;
@@ -160,6 +162,14 @@ const LOGGED_IN_BODY = {
160162
hasLocationSet: false,
161163
location: null,
162164
hideExperience: false,
165+
profileCompletion: {
166+
percentage: 20,
167+
hasProfileImage: true,
168+
hasHeadline: false,
169+
hasExperienceLevel: false,
170+
hasWork: false,
171+
hasEducation: false,
172+
},
163173
},
164174
marketingCta: null,
165175
feeds: [],
@@ -1734,6 +1744,7 @@ describe('funnel boot', () => {
17341744
'hasLocationSet',
17351745
'location',
17361746
'readme',
1747+
'profileCompletion',
17371748
]),
17381749
});
17391750
});
@@ -1932,3 +1943,124 @@ describe('funnel boot', () => {
19321943
});
19331944
});
19341945
});
1946+
1947+
describe('boot profile completion', () => {
1948+
const BASE_PATH = '/boot';
1949+
1950+
it('should return profileCompletion with 0% for user with no profile data', async () => {
1951+
await con
1952+
.getRepository(User)
1953+
.update({ id: '1' }, { image: '', bio: null, experienceLevel: null });
1954+
1955+
mockLoggedIn();
1956+
const res = await request(app.server)
1957+
.get(BASE_PATH)
1958+
.set('User-Agent', TEST_UA)
1959+
.set('Cookie', 'ory_kratos_session=value;')
1960+
.expect(200);
1961+
1962+
expect(res.body.user.profileCompletion).toEqual({
1963+
percentage: 0,
1964+
hasProfileImage: false,
1965+
hasHeadline: false,
1966+
hasExperienceLevel: false,
1967+
hasWork: false,
1968+
hasEducation: false,
1969+
});
1970+
});
1971+
1972+
it('should return profileCompletion with 20% for user with only profile image', async () => {
1973+
await con.getRepository(User).update(
1974+
{ id: '1' },
1975+
{
1976+
image: 'https://example.com/image.jpg',
1977+
bio: null,
1978+
experienceLevel: null,
1979+
},
1980+
);
1981+
1982+
mockLoggedIn();
1983+
const res = await request(app.server)
1984+
.get(BASE_PATH)
1985+
.set('User-Agent', TEST_UA)
1986+
.set('Cookie', 'ory_kratos_session=value;')
1987+
.expect(200);
1988+
1989+
expect(res.body.user.profileCompletion).toEqual({
1990+
percentage: 20,
1991+
hasProfileImage: true,
1992+
hasHeadline: false,
1993+
hasExperienceLevel: false,
1994+
hasWork: false,
1995+
hasEducation: false,
1996+
});
1997+
});
1998+
1999+
it('should return profileCompletion with 60% for user with image, bio, and experience level', async () => {
2000+
await con.getRepository(User).update(
2001+
{ id: '1' },
2002+
{
2003+
image: 'https://example.com/image.jpg',
2004+
bio: 'Software engineer',
2005+
experienceLevel: 'MORE_THAN_4_YEARS',
2006+
},
2007+
);
2008+
2009+
mockLoggedIn();
2010+
const res = await request(app.server)
2011+
.get(BASE_PATH)
2012+
.set('User-Agent', TEST_UA)
2013+
.set('Cookie', 'ory_kratos_session=value;')
2014+
.expect(200);
2015+
2016+
expect(res.body.user.profileCompletion).toEqual({
2017+
percentage: 60,
2018+
hasProfileImage: true,
2019+
hasHeadline: true,
2020+
hasExperienceLevel: true,
2021+
hasWork: false,
2022+
hasEducation: false,
2023+
});
2024+
});
2025+
2026+
it('should return profileCompletion with 100% for user with complete profile', async () => {
2027+
await con.getRepository(User).update(
2028+
{ id: '1' },
2029+
{
2030+
image: 'https://example.com/image.jpg',
2031+
bio: 'Software engineer',
2032+
experienceLevel: 'MORE_THAN_4_YEARS',
2033+
},
2034+
);
2035+
2036+
// Add work experience
2037+
await con.getRepository(UserExperienceWork).save({
2038+
userId: '1',
2039+
title: 'Software Engineer',
2040+
startedAt: new Date('2020-01-01'),
2041+
});
2042+
2043+
// Add education
2044+
await con.getRepository(UserExperienceEducation).save({
2045+
userId: '1',
2046+
title: 'Computer Science',
2047+
startedAt: new Date('2016-01-01'),
2048+
});
2049+
2050+
mockLoggedIn();
2051+
const res = await request(app.server)
2052+
.get(BASE_PATH)
2053+
.set('User-Agent', TEST_UA)
2054+
.set('Cookie', 'ory_kratos_session=value;')
2055+
.expect(200);
2056+
2057+
expect(res.body.user.profileCompletion).toEqual({
2058+
percentage: 100,
2059+
hasProfileImage: true,
2060+
hasHeadline: true,
2061+
hasExperienceLevel: true,
2062+
hasWork: true,
2063+
hasEducation: true,
2064+
});
2065+
});
2066+
});

src/routes/boot.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
SquadSource,
2424
User,
2525
} from '../entity';
26+
import { UserExperience } from '../entity/user/experiences/UserExperience';
27+
import { UserExperienceType } from '../entity/user/experiences/types';
2628
import { DatasetLocation } from '../entity/dataset/DatasetLocation';
2729
import {
2830
getPermissionsForMember,
@@ -45,6 +47,7 @@ import {
4547
StorageKey,
4648
StorageTopic,
4749
} from '../config';
50+
4851
import {
4952
ONE_DAY_IN_SECONDS,
5053
base64,
@@ -148,6 +151,7 @@ export type LoggedInBoot = BaseBoot & {
148151
balance: GetBalanceResult;
149152
coresRole: CoresRole;
150153
location?: TLocation | null;
154+
profileCompletion?: ProfileCompletion | null;
151155
};
152156
accessToken?: AccessToken;
153157
marketingCta: MarketingCta | null;
@@ -525,6 +529,80 @@ const getLocation = async (
525529
return location;
526530
};
527531

532+
export type ProfileCompletion = {
533+
percentage: number;
534+
hasProfileImage: boolean;
535+
hasHeadline: boolean;
536+
hasExperienceLevel: boolean;
537+
hasWork: boolean;
538+
hasEducation: boolean;
539+
};
540+
541+
type ProfileExperienceFlags = {
542+
hasWork: boolean;
543+
hasEducation: boolean;
544+
};
545+
546+
const getProfileExperienceFlags = async (
547+
con: DataSource | QueryRunner,
548+
userId: string,
549+
): Promise<ProfileExperienceFlags> => {
550+
const result = await con.manager
551+
.createQueryBuilder(UserExperience, 'ue')
552+
.select(`MAX(CASE WHEN ue.type = :workType THEN 1 ELSE 0 END)`, 'hasWork')
553+
.addSelect(
554+
`MAX(CASE WHEN ue.type = :educationType THEN 1 ELSE 0 END)`,
555+
'hasEducation',
556+
)
557+
.where('ue.userId = :userId', { userId })
558+
.andWhere('ue.type IN (:...types)', {
559+
types: [UserExperienceType.Work, UserExperienceType.Education],
560+
})
561+
.setParameters({
562+
workType: UserExperienceType.Work,
563+
educationType: UserExperienceType.Education,
564+
})
565+
.getRawOne();
566+
567+
const hasWork = result?.hasWork == 1;
568+
const hasEducation = result?.hasEducation == 1;
569+
570+
return { hasWork, hasEducation };
571+
};
572+
573+
const calculateProfileCompletion = (
574+
user: User | null,
575+
experienceFlags: ProfileExperienceFlags | null,
576+
): ProfileCompletion | null => {
577+
if (!user || !experienceFlags) {
578+
return null;
579+
}
580+
581+
// Calculate completion based on 5 items (each worth 20%)
582+
const hasProfileImage = !!user.image && user.image !== '';
583+
const hasHeadline = !!user.bio && user.bio.trim() !== '';
584+
const hasExperienceLevel = !!user.experienceLevel;
585+
const { hasWork, hasEducation } = experienceFlags;
586+
587+
const completedItems = [
588+
hasProfileImage,
589+
hasHeadline,
590+
hasExperienceLevel,
591+
hasWork,
592+
hasEducation,
593+
].filter(Boolean).length;
594+
595+
const percentage = Math.round((completedItems / 5) * 100);
596+
return {
597+
percentage,
598+
hasProfileImage,
599+
hasHeadline,
600+
hasExperienceLevel,
601+
hasWork,
602+
hasEducation,
603+
};
604+
};
605+
528606
const loggedInBoot = async ({
529607
con,
530608
req,
@@ -546,6 +624,7 @@ const loggedInBoot = async ({
546624
const geo = geoSection(req);
547625

548626
const { log } = req;
627+
549628
const [
550629
visit,
551630
roles,
@@ -559,6 +638,7 @@ const loggedInBoot = async ({
559638
feeds,
560639
unreadNotificationsCount,
561640
location,
641+
experienceFlags,
562642
],
563643
balance,
564644
clickbaitTries,
@@ -582,11 +662,15 @@ const loggedInBoot = async ({
582662
getFeeds({ con: queryRunner, userId }),
583663
getUnreadNotificationsCount(queryRunner, userId),
584664
getLocation(queryRunner, userId),
665+
getProfileExperienceFlags(queryRunner, userId),
585666
]);
586667
}),
587668
getBalanceBoot({ userId }),
588669
getClickbaitTries({ userId }),
589670
]);
671+
672+
const profileCompletion = calculateProfileCompletion(user, experienceFlags);
673+
590674
if (!user) {
591675
return handleNonExistentUser(con, req, res, middleware);
592676
}
@@ -641,6 +725,7 @@ const loggedInBoot = async ({
641725
clickbaitTries,
642726
hasLocationSet,
643727
location,
728+
profileCompletion,
644729
},
645730
visit,
646731
alerts: {

0 commit comments

Comments
 (0)