feat(client): add job experience widget to profile (#63503)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
This commit is contained in:
Sem Bauke
2026-01-22 09:30:50 +01:00
committed by GitHub
parent 59a54b5040
commit f68b471d6d
36 changed files with 1274 additions and 29 deletions
+22 -10
View File
@@ -55,17 +55,28 @@ type Portfolio {
url String
}
type Experience {
id String
title String
company String
location String?
startDate String
endDate String?
description String
}
type ProfileUI {
isLocked Boolean? // Undefined
showAbout Boolean? // Undefined
showCerts Boolean? // Undefined
showDonation Boolean? // Undefined
showHeatMap Boolean? // Undefined
showLocation Boolean? // Undefined
showName Boolean? // Undefined
showPoints Boolean? // Undefined
showPortfolio Boolean? // Undefined
showTimeLine Boolean? // Undefined
isLocked Boolean? // Undefined
showAbout Boolean? // Undefined
showCerts Boolean? // Undefined
showDonation Boolean? // Undefined
showHeatMap Boolean? // Undefined
showLocation Boolean? // Undefined
showName Boolean? // Undefined
showPoints Boolean? // Undefined
showPortfolio Boolean? // Undefined
showExperience Boolean? // Undefined
showTimeLine Boolean? // Undefined
}
type SavedChallengeFile {
@@ -152,6 +163,7 @@ model user {
password String? // Undefined
picture String?
portfolio Portfolio[]
experience Experience[]
profileUI ProfileUI? // Undefined
progressTimestamps Json? // ProgressTimestamp[] | Null[] | Int64[] | Double[] - TODO: NORMALIZE
/// A random number between 0 and 1.
+1 -1
View File
@@ -64,7 +64,7 @@ type FastifyInstanceWithTypeProvider = FastifyInstance<
const ajv = new Ajv({
coerceTypes: 'array', // change data type of data to match type keyword
useDefaults: true, // replace missing properties and items with the values from corresponding default keyword
removeAdditional: true, // remove additional properties
removeAdditional: 'all', // remove additional properties
uriResolver,
addUsedSchema: false,
// Explicitly set allErrors to `false`.
+2
View File
@@ -21,6 +21,7 @@ export const newUser = (email: string) => ({
emailAuthLinkTTL: null,
emailVerified: true,
emailVerifyTTL: null,
experience: [],
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
externalId: expect.stringMatching(uuidRe),
githubProfile: null,
@@ -80,6 +81,7 @@ export const newUser = (email: string) => ({
showAbout: false,
showCerts: false,
showDonation: false,
showExperience: false,
showHeatMap: false,
showLocation: false,
showName: false,
+231 -1
View File
@@ -32,6 +32,7 @@ const baseProfileUI = {
showAbout: false,
showCerts: false,
showDonation: false,
showExperience: false,
showHeatMap: false,
showLocation: false,
showName: false,
@@ -1192,6 +1193,234 @@ Happy coding!
});
});
describe('/update-my-experience', () => {
test('PUT returns 200 status code with "success" message and saves experience', async () => {
const payload = {
experience: [
{
id: '1',
title: 'Software Engineer',
company: 'Tech Corp',
location: 'Remote',
startDate: '2020-01',
endDate: '2022-06',
description: 'Worked on various projects'
}
]
};
const response = await superPut('/update-my-experience').send(payload);
expect(response.body).toEqual({
message: 'flash.experience-updated',
type: 'success'
});
expect(response.statusCode).toEqual(200);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: developerUserEmail },
select: { experience: true }
});
expect(user?.experience).toEqual(payload.experience);
});
test('rejects extraneous keys on entries', async () => {
const res = await superPut('/update-my-experience').send({
experience: [
{
id: 'x',
title: 'Dev',
company: 'Co',
startDate: '',
description: '',
foo: 'bar'
}
]
});
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: developerUserEmail },
select: { experience: true }
});
expect(user?.experience).toEqual([
{
id: 'x',
title: 'Dev',
company: 'Co',
location: null,
startDate: '',
endDate: null,
description: ''
}
]);
expect(res.statusCode).toBe(200);
});
test('returns 400 when experience is not an array', async () => {
const response = await superPut('/update-my-experience').send({
experience: { not: 'an array' } as unknown as []
});
expect(response.body).toEqual(updateErrorResponse);
expect(response.statusCode).toEqual(400);
});
test('supports current position (omitted endDate becomes null)', async () => {
const response = await superPut('/update-my-experience').send({
experience: [
{
id: 'cur',
title: 'Engineer',
company: 'Now Co',
startDate: '2023-01',
description: ''
// endDate omitted
}
]
});
expect(response.statusCode).toEqual(200);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: developerUserEmail },
select: { experience: true }
});
expect(user?.experience?.[0]).toEqual({
id: 'cur',
title: 'Engineer',
company: 'Now Co',
location: null,
startDate: '2023-01',
endDate: null,
description: ''
});
});
test('accepts long descriptions', async () => {
const long = 'x'.repeat(1000);
const response = await superPut('/update-my-experience').send({
experience: [
{
id: '',
title: 'Writer',
company: 'Docs Inc',
startDate: '2020-01',
endDate: '2020-12',
description: long
}
]
});
expect(response.statusCode).toEqual(200);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: developerUserEmail },
select: { experience: true }
});
expect(user?.experience?.[0]?.description).toEqual(long);
});
test('PUT accepts empty array and clears experience', async () => {
// seed with one item first
await superPut('/update-my-experience').send({
experience: [
{
id: 'seed',
title: 'Seed Title',
company: 'Seed Co',
location: 'Seed City',
startDate: '2019-01',
endDate: '2019-12',
description: 'Seed desc'
}
]
});
const response = await superPut('/update-my-experience').send({
experience: []
});
expect(response.body).toEqual({
message: 'flash.experience-updated',
type: 'success'
});
expect(response.statusCode).toEqual(200);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: developerUserEmail },
select: { experience: true }
});
expect(user?.experience).toEqual([]);
});
test('PUT saves multiple experiences and preserves order', async () => {
const payload = {
experience: [
{
id: '1',
title: 'Junior Dev',
company: 'A Inc',
location: 'NY',
startDate: '2018-01',
endDate: '2019-01',
description: 'Did stuff'
},
{
id: '2',
title: 'Senior Dev',
company: 'B LLC',
location: 'SF',
startDate: '2019-02',
endDate: '2021-03',
description: 'Did more stuff'
}
]
};
const response = await superPut('/update-my-experience').send(payload);
expect(response.statusCode).toEqual(200);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: developerUserEmail },
select: { experience: true }
});
expect(user?.experience).toEqual(payload.experience);
});
test('PUT returns 400 status code when the experience property is missing', async () => {
const response = await superPut('/update-my-experience').send({});
expect(response.body).toEqual(updateErrorResponse);
expect(response.statusCode).toEqual(400);
});
test('PUT returns 400 status code when any data is the wrong type', async () => {
const response = await superPut('/update-my-experience').send({
experience: [
{
id: '',
title: '',
company: '',
location: '',
startDate: '',
endDate: '',
description: ''
},
{
id: '',
title: {},
company: '',
location: '',
startDate: '',
endDate: '',
description: ''
}
]
});
expect(response.body).toEqual(updateErrorResponse);
expect(response.statusCode).toEqual(400);
});
});
describe('/update-my-classroom-mode', () => {
test('PUT returns 200 status code with "success" message', async () => {
const response = await superPut('/update-my-classroom-mode').send({
@@ -1263,7 +1492,8 @@ Happy coding!
{ path: '/update-my-about', method: 'PUT' },
{ path: '/update-my-honesty', method: 'PUT' },
{ path: '/update-privacy-terms', method: 'PUT' },
{ path: '/update-my-portfolio', method: 'PUT' }
{ path: '/update-my-portfolio', method: 'PUT' },
{ path: '/update-my-experience', method: 'PUT' }
];
endpoints.forEach(({ path, method }) => {
+31
View File
@@ -166,6 +166,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
showName: req.body.profileUI.showName,
showPoints: req.body.profileUI.showPoints,
showPortfolio: req.body.profileUI.showPortfolio,
showExperience: req.body.profileUI.showExperience,
showTimeLine: req.body.profileUI.showTimeLine
}
}
@@ -711,6 +712,36 @@ ${isLinkSentWithinLimitTTL}`
}
);
fastify.put(
'/update-my-experience',
{
schema: schemas.updateMyExperience
},
async (req, reply) => {
const logger = fastify.log.child({ req, res: reply });
try {
const { experience } = req.body;
await fastify.prisma.user.update({
where: { id: req.user?.id },
data: {
experience
}
});
return {
message: 'flash.experience-updated',
type: 'success'
} as const;
} catch (err) {
logger.error(err);
fastify.Sentry.captureException(err);
void reply.code(500);
return { message: 'flash.wrong-updating', type: 'danger' } as const;
}
}
);
fastify.put(
'/update-my-classroom-mode',
{
+3
View File
@@ -177,6 +177,7 @@ const lockedProfileUI = {
showAbout: false,
showCerts: false,
showDonation: false,
showExperience: false,
showHeatMap: false,
showLocation: false,
showName: false,
@@ -271,6 +272,7 @@ const publicUserData = {
completedExams: testUserData.completedExams,
completedSurveys: [], // TODO: add surveys
quizAttempts: testUserData.quizAttempts,
experience: [],
githubProfile: testUserData.githubProfile,
is2018DataVisCert: testUserData.is2018DataVisCert,
is2018FullStackCert: testUserData.is2018FullStackCert, // TODO: should this be returned? The client doesn't use it at the moment.
@@ -1005,6 +1007,7 @@ describe('userRoutes', () => {
completedDailyCodingChallenges: [],
completedExams: [],
completedSurveys: [],
experience: [],
partiallyCompletedChallenges: [],
portfolio: [],
savedChallenges: [],
+3
View File
@@ -722,6 +722,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
partiallyCompletedChallenges: true,
picture: true,
portfolio: true,
experience: true,
profileUI: true,
progressTimestamps: true,
savedChallenges: true,
@@ -781,6 +782,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
location,
name,
theme,
experience,
...publicUser
} = rest;
@@ -818,6 +820,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
usernameDisplay: usernameDisplay || username,
userToken: encodedToken,
completedSurveys: normalizeSurveys(completedSurveys),
experience: experience.map(removeNulls),
msUsername: msUsername?.msUsername
}
},
+5 -1
View File
@@ -127,6 +127,7 @@ const lockedProfileUI = {
showAbout: false,
showCerts: false,
showDonation: false,
showExperience: false,
showHeatMap: false,
showLocation: false,
showName: false,
@@ -253,6 +254,7 @@ describe('userRoutes', () => {
showAbout: true,
showCerts: true,
showDonation: true,
showExperience: false,
showHeatMap: true,
showLocation: true,
showName: true,
@@ -485,6 +487,7 @@ describe('get-public-profile helpers', () => {
description: 'description'
}
],
experience: [],
profileUI: {
isLocked: false,
showAbout: true,
@@ -495,7 +498,8 @@ describe('get-public-profile helpers', () => {
showName: true,
showPoints: true,
showPortfolio: true,
showTimeLine: true
showTimeLine: true,
showExperience: true
}
};
+8 -3
View File
@@ -1,4 +1,4 @@
import { Portfolio } from '@prisma/client';
import { Experience, Portfolio } from '@prisma/client';
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { ObjectId } from 'mongodb';
import { omit } from 'lodash-es';
@@ -33,6 +33,7 @@ type ProfileUI = Partial<{
showName: boolean;
showPoints: boolean;
showPortfolio: boolean;
showExperience: boolean;
showTimeLine: boolean;
}>;
@@ -47,6 +48,7 @@ type RawUser = {
name: string;
points: number;
portfolio: Portfolio[];
experience: Experience[];
profileUI: ProfileUI;
};
@@ -65,6 +67,7 @@ export const replacePrivateData = (user: RawUser) => {
showName,
showPoints,
showPortfolio,
showExperience,
showTimeLine
} = user.profileUI;
@@ -83,7 +86,8 @@ export const replacePrivateData = (user: RawUser) => {
location: showLocation ? user.location : '',
name: showName ? user.name : '',
points: showPoints ? user.points : null,
portfolio: showPortfolio ? user.portfolio : []
portfolio: showPortfolio ? user.portfolio : [],
experience: showExperience ? user.experience : []
};
};
@@ -185,7 +189,8 @@ export const userPublicGetRoutes: FastifyPluginCallbackTypebox = (
joinDate: new ObjectId(user.id).getTimestamp().toISOString(),
name: user.name ?? '',
points: getPoints(progressTimestamps),
profileUI: normalizedProfileUI
profileUI: normalizedProfileUI,
experience: user.experience ?? []
});
const returnedUser = {
+1
View File
@@ -24,6 +24,7 @@ export { updateMyAbout } from './schemas/settings/update-my-about.js';
export { confirmEmail } from './schemas/settings/confirm-email.js';
export { updateMyClassroomMode } from './schemas/settings/update-my-classroom-mode.js';
export { updateMyEmail } from './schemas/settings/update-my-email.js';
export { updateMyExperience } from './schemas/settings/update-my-experience.js';
export { updateMyHonesty } from './schemas/settings/update-my-honesty.js';
export { updateMyKeyboardShortcuts } from './schemas/settings/update-my-keyboard-shortcuts.js';
export { updateMyPortfolio } from './schemas/settings/update-my-portfolio.js';
@@ -0,0 +1,34 @@
import { Type } from '@fastify/type-provider-typebox';
export const updateMyExperience = {
body: Type.Object({
experience: Type.Array(
Type.Object(
{
id: Type.String(),
title: Type.String(),
company: Type.String(),
location: Type.Optional(Type.String()),
startDate: Type.String(),
endDate: Type.Optional(Type.String()),
description: Type.String()
},
{ additionalProperties: false }
)
)
}),
response: {
200: Type.Object({
message: Type.Literal('flash.experience-updated'),
type: Type.Literal('success')
}),
400: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
};
@@ -12,6 +12,7 @@ export const updateMyProfileUI = {
showName: Type.Boolean(),
showPoints: Type.Boolean(),
showPortfolio: Type.Boolean(),
showExperience: Type.Boolean(),
showTimeLine: Type.Boolean()
})
}),
+12 -1
View File
@@ -79,5 +79,16 @@ export const profileUI = Type.Object({
showName: Type.Optional(Type.Boolean()),
showPoints: Type.Optional(Type.Boolean()),
showPortfolio: Type.Optional(Type.Boolean()),
showTimeLine: Type.Optional(Type.Boolean())
showTimeLine: Type.Optional(Type.Boolean()),
showExperience: Type.Optional(Type.Boolean())
});
export const experience = Type.Object({
id: Type.String(),
title: Type.String(),
company: Type.String(),
location: Type.Optional(Type.String()),
startDate: Type.String(),
endDate: Type.Optional(Type.String()),
description: Type.String()
});
+7 -1
View File
@@ -1,5 +1,10 @@
import { Type } from '@fastify/type-provider-typebox';
import { examResults, profileUI, savedChallenge } from '../types.js';
import {
examResults,
profileUI,
savedChallenge,
experience
} from '../types.js';
const languages = Type.Array(
Type.Union([Type.Literal('javascript'), Type.Literal('python')])
@@ -118,6 +123,7 @@ export const getSessionUser = {
url: Type.String()
})
),
experience: Type.Optional(Type.Array(experience)),
profileUI: Type.Optional(profileUI),
sendQuincyEmail: Type.Union([Type.Null(), Type.Boolean()]), // // Tri-state: null (likely new user), true (subscribed), false (unsubscribed)
theme: Type.String(),
+1
View File
@@ -87,6 +87,7 @@ export function createUserInput(email: string) {
showAbout: false,
showCerts: false,
showDonation: false,
showExperience: false,
showHeatMap: false,
showLocation: false,
showName: false,
+8 -4
View File
@@ -50,7 +50,8 @@ describe('normalize', () => {
showName: true,
showPoints: true,
showPortfolio: true,
showTimeLine: true
showTimeLine: true,
showExperience: true
};
const defaultProfileUI = {
@@ -63,7 +64,8 @@ describe('normalize', () => {
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
showTimeLine: false,
showExperience: false
};
describe('normalizeProfileUI', () => {
@@ -87,7 +89,8 @@ describe('normalize', () => {
showName: null,
showPoints: null,
showPortfolio: null,
showTimeLine: null
showTimeLine: null,
showExperience: null
};
expect(normalizeProfileUI(input)).toEqual({
isLocked: undefined,
@@ -99,7 +102,8 @@ describe('normalize', () => {
showName: undefined,
showPoints: undefined,
showPortfolio: undefined,
showTimeLine: undefined
showTimeLine: undefined,
showExperience: undefined
});
});
});
+2 -1
View File
@@ -137,7 +137,8 @@ export const normalizeProfileUI = (
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
showTimeLine: false,
showExperience: false
};
};