Skip to content

Commit a1da777

Browse files
authored
feat: add user job preferences support (#2953)
1 parent 38b3134 commit a1da777

File tree

6 files changed

+580
-3
lines changed

6 files changed

+580
-3
lines changed

__tests__/users.ts

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ import * as googleCloud from '../src/common/googleCloud';
142142
import { RESUMES_BUCKET_NAME } from '../src/common/googleCloud';
143143
import { fileTypeFromBuffer } from './setup';
144144
import { Bucket } from '@google-cloud/storage';
145+
import { UserWorkExperience } from '../src/entity/user/experiences/UserWorkExperience';
146+
import { ExperienceStatus } from '../src/entity/user/experiences/types';
147+
import {
148+
UserJobPreferences,
149+
WorkLocationType,
150+
} from '../src/entity/user/UserJobPreferences';
145151

146152
jest.mock('../src/common/geo', () => ({
147153
...(jest.requireActual('../src/common/geo') as Record<string, unknown>),
@@ -6991,3 +6997,301 @@ describe('mutation uploadResume', () => {
69916997
expect(body.errors[0].message).toEqual('File type not supported');
69926998
});
69936999
});
7000+
7001+
describe('user job preferences', () => {
7002+
const QUERY = `
7003+
query UserJobPreferences {
7004+
userJobPreferences {
7005+
openToOpportunities
7006+
preferredRoles
7007+
preferredLocationType
7008+
openToRelocation
7009+
currentTotalComp {
7010+
currency
7011+
amount
7012+
}
7013+
}
7014+
}
7015+
`;
7016+
7017+
beforeEach(async () => {
7018+
// Set up a user with job preferences
7019+
await con.getRepository(UserJobPreferences).save({
7020+
userId: '1',
7021+
openToOpportunities: true,
7022+
preferredRoles: ['Software Engineer', 'Frontend Developer'],
7023+
preferredLocationType: 'remote',
7024+
openToRelocation: false,
7025+
currentTotalComp: {
7026+
currency: 'USD',
7027+
amount: 10,
7028+
},
7029+
});
7030+
7031+
await saveFixtures(con, UserJobPreferences, [
7032+
{
7033+
userId: '1',
7034+
openToOpportunities: true,
7035+
preferredRoles: ['Software Engineer', 'Frontend Developer'],
7036+
preferredLocationType: WorkLocationType.Remote,
7037+
openToRelocation: false,
7038+
currentTotalComp: {
7039+
currency: 'USD',
7040+
amount: 10,
7041+
},
7042+
},
7043+
{
7044+
userId: '2',
7045+
openToOpportunities: true,
7046+
preferredRoles: [],
7047+
preferredLocationType: WorkLocationType.Remote,
7048+
openToRelocation: false,
7049+
currentTotalComp: {
7050+
currency: 'USD',
7051+
amount: 10,
7052+
},
7053+
},
7054+
]);
7055+
7056+
// Set up a user with a complete profile
7057+
await con.getRepository(User).update('1', {
7058+
flags: {
7059+
country: 'US',
7060+
},
7061+
});
7062+
7063+
// Add work experience to make sure profile is complete
7064+
await saveFixtures(con, UserWorkExperience, [
7065+
{
7066+
userId: '1',
7067+
title: 'Software Engineer',
7068+
status: ExperienceStatus.Published,
7069+
description: '',
7070+
startDate: new Date(),
7071+
},
7072+
{
7073+
userId: '1',
7074+
title: 'Senior Software Engineer',
7075+
status: ExperienceStatus.Published,
7076+
description: '',
7077+
startDate: new Date(),
7078+
},
7079+
{
7080+
userId: '1',
7081+
title: 'Software Developer',
7082+
status: ExperienceStatus.Published,
7083+
description: '',
7084+
startDate: new Date(),
7085+
},
7086+
{
7087+
userId: '1',
7088+
title: 'Frontend Developer',
7089+
status: ExperienceStatus.Published,
7090+
description: '',
7091+
startDate: new Date(),
7092+
},
7093+
{
7094+
userId: '1',
7095+
title: 'Draft Job Title',
7096+
status: ExperienceStatus.Draft,
7097+
description: '',
7098+
startDate: new Date(),
7099+
},
7100+
]);
7101+
});
7102+
7103+
describe('query userJobPreferences', () => {
7104+
it('should throw error on query as guest', async () => {
7105+
return testQueryErrorCode(client, { query: QUERY }, 'UNAUTHENTICATED');
7106+
});
7107+
7108+
it('should return user job preferences', async () => {
7109+
loggedUser = '1';
7110+
const res = await client.query(QUERY);
7111+
expect(res.errors).toBeFalsy();
7112+
expect(res.data.userJobPreferences).toMatchObject({
7113+
openToOpportunities: true,
7114+
preferredRoles: ['Software Engineer', 'Frontend Developer'],
7115+
preferredLocationType: 'remote',
7116+
openToRelocation: false,
7117+
currentTotalComp: {
7118+
currency: 'USD',
7119+
amount: 10,
7120+
},
7121+
});
7122+
});
7123+
7124+
it('should force openToOpportunities to false if profile is not complete', async () => {
7125+
loggedUser = '2'; // User without job preferences
7126+
const res = await client.query(QUERY);
7127+
expect(res.errors).toBeFalsy();
7128+
expect(res.data.userJobPreferences).toMatchObject({
7129+
// fetched as false even if is true on preferences since have no experiences
7130+
openToOpportunities: false,
7131+
preferredRoles: [],
7132+
preferredLocationType: WorkLocationType.Remote,
7133+
openToRelocation: false,
7134+
currentTotalComp: {},
7135+
});
7136+
});
7137+
});
7138+
7139+
describe('mutation updateUserJobPreferences', () => {
7140+
const MUTATION = `
7141+
mutation UpdateUserJobPreferences(
7142+
$openToOpportunities: Boolean!
7143+
$preferredRoles: [String!]!
7144+
$preferredLocationType: String
7145+
$openToRelocation: Boolean!
7146+
$currentTotalComp: UserTotalCompensationInput!
7147+
) {
7148+
updateUserJobPreferences(
7149+
openToOpportunities: $openToOpportunities
7150+
preferredRoles: $preferredRoles
7151+
preferredLocationType: $preferredLocationType
7152+
openToRelocation: $openToRelocation
7153+
currentTotalComp: $currentTotalComp
7154+
) {
7155+
openToOpportunities
7156+
preferredRoles
7157+
preferredLocationType
7158+
openToRelocation
7159+
currentTotalComp {
7160+
currency
7161+
amount
7162+
}
7163+
}
7164+
}
7165+
`;
7166+
7167+
it('should throw error on mutation as guest', async () => {
7168+
return testMutationErrorCode(
7169+
client,
7170+
{
7171+
mutation: MUTATION,
7172+
variables: {
7173+
openToOpportunities: true,
7174+
preferredRoles: ['Software Engineer'],
7175+
preferredLocationType: 'remote',
7176+
openToRelocation: false,
7177+
currentTotalComp: {},
7178+
},
7179+
},
7180+
'UNAUTHENTICATED',
7181+
);
7182+
});
7183+
7184+
it('should update user job preferences', async () => {
7185+
loggedUser = '1';
7186+
const variables = {
7187+
openToOpportunities: false,
7188+
preferredRoles: ['Product Manager', 'Project Manager'],
7189+
preferredLocationType: WorkLocationType.Remote,
7190+
openToRelocation: true,
7191+
currentTotalComp: {
7192+
currency: 'EUR',
7193+
amount: 10,
7194+
},
7195+
};
7196+
7197+
const res = await client.mutate(MUTATION, { variables });
7198+
expect(res.errors).toBeFalsy();
7199+
expect(res.data.updateUserJobPreferences).toMatchObject({
7200+
openToOpportunities: false,
7201+
preferredRoles: ['Product Manager', 'Project Manager'],
7202+
preferredLocationType: WorkLocationType.Remote,
7203+
openToRelocation: true,
7204+
currentTotalComp: {
7205+
currency: 'EUR',
7206+
amount: 10,
7207+
},
7208+
});
7209+
7210+
// Verify database state
7211+
const updatedPrefs = await con
7212+
.getRepository('UserJobPreferences')
7213+
.findOne({
7214+
where: { userId: '1' },
7215+
});
7216+
expect(updatedPrefs).toMatchObject({
7217+
openToOpportunities: false,
7218+
preferredRoles: ['Product Manager', 'Project Manager'],
7219+
preferredLocationType: WorkLocationType.Remote,
7220+
openToRelocation: true,
7221+
currentTotalComp: {
7222+
currency: 'EUR',
7223+
amount: 10,
7224+
},
7225+
});
7226+
});
7227+
7228+
it('should throw error on invalid job preferences', async () => {
7229+
loggedUser = '1';
7230+
7231+
// Test with too many preferred roles
7232+
const variables = {
7233+
openToOpportunities: true,
7234+
preferredRoles: [
7235+
'Role 1',
7236+
'Role 2',
7237+
'Role 3',
7238+
'Role 4',
7239+
'Role 5',
7240+
'Role 6',
7241+
],
7242+
preferredLocationType: 'remote',
7243+
openToRelocation: false,
7244+
currentTotalComp: {},
7245+
};
7246+
7247+
return testMutationErrorCode(
7248+
client,
7249+
{ mutation: MUTATION, variables },
7250+
'GRAPHQL_VALIDATION_FAILED',
7251+
'Invalid job preferences data. Please check your input.',
7252+
);
7253+
});
7254+
7255+
it('should not update user job preferences of other user', async () => {
7256+
// Create job preferences for user 2
7257+
await con.getRepository('UserJobPreferences').save({
7258+
userId: '2',
7259+
openToOpportunities: false,
7260+
preferredRoles: ['Designer'],
7261+
preferredLocationType: 'on_site',
7262+
openToRelocation: true,
7263+
currentTotalComp: {
7264+
currency: 'GBP',
7265+
amount: 20,
7266+
},
7267+
});
7268+
7269+
// Log in as user 1
7270+
loggedUser = '1';
7271+
7272+
// Try to update user 2's preferences by setting userId
7273+
const variables = {
7274+
userId: '2', // This should be ignored
7275+
openToOpportunities: true,
7276+
preferredRoles: ['Hacker'],
7277+
preferredLocationType: 'remote',
7278+
openToRelocation: false,
7279+
currentTotalComp: {},
7280+
};
7281+
7282+
const res = await client.mutate(MUTATION, { variables });
7283+
expect(res.errors).toBeFalsy();
7284+
7285+
// Verify that user 1's preferences were updated, not user 2's
7286+
const user1Prefs = await con.getRepository('UserJobPreferences').findOne({
7287+
where: { userId: '1' },
7288+
});
7289+
expect(user1Prefs?.preferredRoles).toContain('Hacker');
7290+
7291+
const user2Prefs = await con.getRepository('UserJobPreferences').findOne({
7292+
where: { userId: '2' },
7293+
});
7294+
expect(user2Prefs?.preferredRoles).toEqual(['Designer']);
7295+
});
7296+
});
7297+
});

src/common/users.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ import { queryReadReplica } from './queryReadReplica';
2121
import { logger } from '../logger';
2222
import type { GQLKeyword } from '../schema/keywords';
2323
import type { GQLUser } from '../schema/users';
24+
import {
25+
ExperienceStatus,
26+
UserExperienceType,
27+
} from '../entity/user/experiences/types';
28+
import { z } from 'zod';
29+
import { WorkLocationType } from '../entity/user/UserJobPreferences';
2430

2531
export interface User {
2632
id: string;
@@ -636,3 +642,69 @@ export const bskySocialUrlMatch =
636642
/^(?:(?:https:\/\/)?(?:www\.)?bsky\.app\/profile\/)?(?<value>[\w.-]+)(?:\/.*)?$/;
637643

638644
export const portfolioLimit = 500;
645+
646+
const MIN_WORK_EXPERIENCE = 1; // Minimum number of work experiences required for profile completion
647+
export const isProfileCompleteById = async (
648+
con: DataSource,
649+
userId: User['id'],
650+
) => {
651+
try {
652+
const [user, experiencesCount] = await queryReadReplica(
653+
con,
654+
({ queryRunner }) =>
655+
Promise.all([
656+
queryRunner.manager
657+
.getRepository('User')
658+
.findOneByOrFail({ id: userId }),
659+
queryRunner.manager.getRepository('UserExperience').count({
660+
where: {
661+
userId,
662+
type: In([UserExperienceType.Work, UserExperienceType.Education]),
663+
status: ExperienceStatus.Published,
664+
},
665+
}),
666+
]),
667+
);
668+
669+
const hasCountry =
670+
typeof user.flags.country === 'string' && user.flags.country.length >= 2;
671+
const hasEnoughExperiences = experiencesCount >= MIN_WORK_EXPERIENCE;
672+
673+
return hasCountry && hasEnoughExperiences;
674+
} catch {
675+
return false;
676+
}
677+
};
678+
679+
const jobPreferenceUpdateValidation = z.object({
680+
openToOpportunities: z.boolean().optional().default(false),
681+
preferredRoles: z
682+
.array(
683+
z
684+
.string()
685+
.min(3, 'Preferred roles must be at least 3 characters long')
686+
.max(50, 'Preferred roles must be at most 50 characters long'),
687+
)
688+
.max(5, 'Preferred roles can have a maximum of 5 items')
689+
.optional()
690+
.default([]),
691+
preferredLocationType: z.nativeEnum(WorkLocationType).optional(),
692+
openToRelocation: z.boolean().optional().default(false),
693+
currentTotalComp: z
694+
.object({
695+
currency: z.string().length(3),
696+
amount: z.number().int().positive(),
697+
})
698+
.partial()
699+
.default({}),
700+
});
701+
export const checkJobPreferenceParamsValidity = (params: unknown) => {
702+
const result = jobPreferenceUpdateValidation.safeParse(params);
703+
if (!result.success) {
704+
logger.error(
705+
{ error: result.error, params },
706+
'Invalid job preference update parameters',
707+
);
708+
}
709+
return result;
710+
};

0 commit comments

Comments
 (0)