feat(api): api/users/get-public-profile (#54729)

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Oliver Eyton-Williams
2024-06-10 19:46:55 +02:00
committed by GitHub
parent 02234419a9
commit a8f7e15dc2
12 changed files with 715 additions and 98 deletions
+2 -1
View File
@@ -41,7 +41,7 @@ import { donateRoutes } from './routes/donate';
import { emailSubscribtionRoutes } from './routes/email-subscription';
import { settingRoutes } from './routes/settings';
import { statusRoute } from './routes/status';
import { userGetRoutes, userRoutes } from './routes/user';
import { userGetRoutes, userRoutes, userPublicGetRoutes } from './routes/user';
import {
API_LOCATION,
COOKIE_DOMAIN,
@@ -208,6 +208,7 @@ export const build = async (
void fastify.register(donateRoutes);
void fastify.register(emailSubscribtionRoutes);
void fastify.register(userRoutes);
void fastify.register(userPublicGetRoutes);
void fastify.register(protectedCertificateRoutes);
void fastify.register(unprotectedCertificateRoutes);
void fastify.register(userGetRoutes);
+358 -41
View File
@@ -16,7 +16,7 @@ import {
createSuperRequest
} from '../../jest.utils';
import { JWT_SECRET } from '../utils/env';
import { getMsTranscriptApiUrl } from './user';
import { getMsTranscriptApiUrl, replacePrivateData } from './user';
const mockedFetch = jest.fn();
jest.spyOn(globalThis, 'fetch').mockImplementation(mockedFetch);
@@ -79,7 +79,7 @@ const testUserData: Prisma.userCreateInput = {
],
savedChallenges: [
{
id: 'abc123',
id: 'a6b0bb188d873cb2c8729495',
lastSavedDate: 123,
files: [
{
@@ -110,6 +110,19 @@ const minimalUserData: Prisma.userCreateInput = {
unsubscribeId: '1234567890'
};
const lockedProfileUI = {
isLocked: true,
showAbout: false,
showCerts: false,
showDonation: false,
showHeatMap: false,
showLocation: false,
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
};
// These are not part of the schema, but are added to the user object by
// get-session-user's handler
const computedProperties = {
@@ -119,24 +132,23 @@ const computedProperties = {
points: 1,
// This is the default value if profileUI is missing. If individual properties
// are missing from the db, they will be omitted from the response.
profileUI: {
isLocked: true,
showAbout: false,
showCerts: false,
showDonation: false,
showHeatMap: false,
showLocation: false,
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
}
profileUI: lockedProfileUI
};
// The following appears in get-session-user responses, but not
// get-public-profile
const sessionOnlyData = {
currentChallengeId: testUserData.currentChallengeId,
email: testUserData.email,
emailVerified: testUserData.emailVerified,
isEmailVerified: testUserData.emailVerified,
sendQuincyEmail: testUserData.sendQuincyEmail,
theme: testUserData.theme,
keyboardShortcuts: testUserData.keyboardShortcuts,
completedChallengeCount: 3,
acceptedPrivacyTerms: testUserData.acceptedPrivacyTerms
};
// This is (most of) what we expect to get back from the API. The remaining
// properties are 'id' and 'joinDate', which are generated by the database.
// We're currently filtering properties with null values, since the old api just
// would not return those.
const publicUserData = {
about: testUserData.about,
calendar: { 1520002973: 1, 1520440323: 1 },
@@ -177,32 +189,31 @@ const publicUserData = {
}
],
completedExams: testUserData.completedExams,
completedSurveys: [],
completedSurveys: [], // TODO: add surveys
githubProfile: testUserData.githubProfile,
is2018DataVisCert: testUserData.is2018DataVisCert,
is2018FullStackCert: testUserData.is2018FullStackCert, // TODO: should this be returned? The client doesn't use it at the moment.
isApisMicroservicesCert: testUserData.isApisMicroservicesCert,
isBackEndCert: testUserData.isBackEndCert,
isCheater: testUserData.isCheater,
isDonating: testUserData.isDonating,
isEmailVerified: testUserData.emailVerified,
is2018DataVisCert: testUserData.is2018DataVisCert,
is2018FullStackCert: testUserData.is2018FullStackCert,
isCollegeAlgebraPyCertV8: testUserData.isCollegeAlgebraPyCertV8,
isDataAnalysisPyCertV7: testUserData.isDataAnalysisPyCertV7,
isDataVisCert: testUserData.isDataVisCert,
isDonating: testUserData.isDonating,
isFoundationalCSharpCertV8: testUserData.isFoundationalCSharpCertV8,
isFrontEndCert: testUserData.isFrontEndCert,
isFullStackCert: testUserData.isFullStackCert,
isFrontEndLibsCert: testUserData.isFrontEndLibsCert,
isFullStackCert: testUserData.isFullStackCert,
isHonest: testUserData.isHonest,
isInfosecQaCert: testUserData.isInfosecQaCert,
isQaCertV7: testUserData.isQaCertV7,
isInfosecCertV7: testUserData.isInfosecCertV7,
isInfosecQaCert: testUserData.isInfosecQaCert,
isJsAlgoDataStructCert: testUserData.isJsAlgoDataStructCert,
isJsAlgoDataStructCertV8: testUserData.isJsAlgoDataStructCertV8,
isMachineLearningPyCertV7: testUserData.isMachineLearningPyCertV7,
isQaCertV7: testUserData.isQaCertV7,
isRelationalDatabaseCertV8: testUserData.isRelationalDatabaseCertV8,
isRespWebDesignCert: testUserData.isRespWebDesignCert,
isSciCompPyCertV7: testUserData.isSciCompPyCertV7,
isDataAnalysisPyCertV7: testUserData.isDataAnalysisPyCertV7,
isMachineLearningPyCertV7: testUserData.isMachineLearningPyCertV7,
isCollegeAlgebraPyCertV8: testUserData.isCollegeAlgebraPyCertV8,
linkedin: testUserData.linkedin,
location: testUserData.location,
name: testUserData.name,
@@ -211,19 +222,20 @@ const publicUserData = {
points: 2,
portfolio: testUserData.portfolio,
profileUI: testUserData.profileUI,
savedChallenges: testUserData.savedChallenges,
twitter: 'https://twitter.com/foobar',
username: testUserData.usernameDisplay, // It defaults to usernameDisplay
website: testUserData.website,
yearsTopContributor: testUserData.yearsTopContributor,
currentChallengeId: testUserData.currentChallengeId,
email: testUserData.email,
emailVerified: testUserData.emailVerified,
sendQuincyEmail: testUserData.sendQuincyEmail,
theme: testUserData.theme,
twitter: 'https://twitter.com/foobar',
keyboardShortcuts: testUserData.keyboardShortcuts,
completedChallengeCount: 3,
acceptedPrivacyTerms: testUserData.acceptedPrivacyTerms,
savedChallenges: testUserData.savedChallenges
yearsTopContributor: testUserData.yearsTopContributor
};
// This is (most of) what we expect to get back from the API. The remaining
// properties are 'id' and 'joinDate', which are generated by the database.
// We're currently filtering properties with null values, since the old api just
// would not return those.
const sessionUserData = {
...sessionOnlyData,
...publicUserData
};
const baseProgressData = {
@@ -554,7 +566,7 @@ describe('userRoutes', () => {
where: { email: testUserData.email }
});
const publicUser = {
...publicUserData,
...sessionUserData,
id: testUser?.id,
joinDate: new ObjectId(testUser?.id).getTimestamp().toISOString()
};
@@ -1114,6 +1126,167 @@ Thanks and regards,
});
});
});
describe('Public', () => {
let superGet: ReturnType<typeof createSuperRequest>;
beforeEach(() => {
superGet = createSuperRequest({ method: 'GET' });
});
describe('/api/users/get-public-profile', () => {
const profilelessUsername = 'profileless-user';
const lockedUsername = 'locked-user';
const publicUsername = 'public-user';
const lockedUserProfileUI = {
isLocked: true,
showAbout: true,
showPortfolio: false
};
const unlockedUserProfileUI = {
isLocked: false,
showAbout: true,
showCerts: true,
showDonation: true,
showHeatMap: true,
showLocation: true,
showName: true,
showPoints: true,
showPortfolio: true,
showTimeLine: true
};
const users = [profilelessUsername, lockedUsername, publicUsername];
beforeAll(async () => {
await fastifyTestInstance.prisma.user.create({
data: {
...minimalUserData,
email: profilelessUsername,
username: profilelessUsername
}
});
await fastifyTestInstance.prisma.user.create({
data: {
...minimalUserData,
email: lockedUsername,
username: lockedUsername,
profileUI: lockedUserProfileUI
}
});
await fastifyTestInstance.prisma.user.create({
data: {
...testUserData,
email: publicUsername,
username: publicUsername,
profileUI: unlockedUserProfileUI
}
});
});
afterAll(async () => {
await fastifyTestInstance.prisma.user.deleteMany({
where: {
OR: users.map(username => ({ username }))
}
});
});
describe('GET', () => {
test('returns 400 status code if the username param is missing', async () => {
const res = await superGet('/api/users/get-public-profile');
// TODO(Post-MVP): return something more informative
expect(res.body).toStrictEqual({});
expect(res.statusCode).toBe(400);
});
test('returns 400 status code if the username param is empty', async () => {
const res = await superGet('/api/users/get-public-profile?username=');
// TODO(Post-MVP): return something more informative
expect(res.body).toStrictEqual({});
expect(res.statusCode).toBe(400);
});
test('returns 404 status code for non-existent user', async () => {
const response = await superGet(
'/api/users/get-public-profile?username=non-existent'
);
// TODO(Post-MVP): return something more informative
expect(response.body).toStrictEqual({});
expect(response.statusCode).toBe(404);
});
test('returns 200 status code with a locked profile if the profile is private', async () => {
const response = await superGet(
`/api/users/get-public-profile?username=${lockedUsername}`
);
expect(response.body).toStrictEqual({
entities: {
user: {
[lockedUsername]: {
isLocked: true,
profileUI: lockedUserProfileUI,
username: lockedUsername
}
}
},
result: lockedUsername
});
expect(response.statusCode).toBe(200);
});
test('returns 200 status code locked profile if the profile is missing', async () => {
const response = await superGet(
`/api/users/get-public-profile?username=${profilelessUsername}`
);
expect(response.body).toStrictEqual({
entities: {
user: {
[profilelessUsername]: {
isLocked: true,
profileUI: lockedProfileUI,
username: profilelessUsername
}
}
},
result: profilelessUsername
});
expect(response.statusCode).toBe(200);
});
// TODO: create a list of public properties like the api-server and use that
// to restrict the output of this and get-session-user.
test('returns 200 status code with public user object', async () => {
const testUser =
await fastifyTestInstance.prisma.user.findFirstOrThrow({
where: { email: publicUsername }
});
const response = await superGet(
`/api/users/get-public-profile?username=${publicUsername}`
);
// TODO: create a fixture for this without 'completedSurveys', ideally
// it should contain the entire body.
const publicUser = {
// TODO(Post-MVP, maybe): return completedSurveys?
..._.omit(publicUserData, 'completedSurveys'),
username: publicUsername,
joinDate: new ObjectId(testUser.id).getTimestamp().toISOString(),
profileUI: unlockedUserProfileUI
};
expect(response.body).toStrictEqual({
entities: {
user: {
[publicUsername]: publicUser
}
},
result: publicUsername
});
expect(response.statusCode).toBe(200);
});
});
});
});
});
describe('Microsoft helpers', () => {
@@ -1143,3 +1316,147 @@ describe('Microsoft helpers', () => {
});
});
});
describe('get-public-profile helpers', () => {
describe('replacePrivateData', () => {
const user = {
about: 'about',
calendar: { 1: 1, 2: 1 } as const,
completedChallenges: [
{ id: '123', completedDate: 123, files: [] },
{ id: '456', completedDate: 456, challengeType: 7, files: [] }
],
id: '5f5b1b3b1c9d440000d9e3b4',
isDonating: false,
location: 'location',
joinDate: 'joinDate',
name: 'name',
points: 2,
portfolio: [
{
id: '789',
title: 'portfolio',
url: 'url',
image: 'image',
description: 'description'
}
],
profileUI: {
isLocked: false,
showAbout: true,
showCerts: true,
showDonation: true,
showHeatMap: true,
showLocation: true,
showName: true,
showPoints: true,
showPortfolio: true,
showTimeLine: true
}
};
test(`returns "" for 'about' if showAbout is not true`, () => {
const userWithoutAbout = {
...user,
profileUI: { ...user.profileUI, showAbout: false }
};
expect(replacePrivateData(userWithoutAbout)).toMatchObject({
about: ''
});
});
test('returns {} for calendar if showHeatMap is not true', () => {
const userWithoutHeatMap = {
...user,
profileUI: { ...user.profileUI, showHeatMap: false }
};
expect(replacePrivateData(userWithoutHeatMap).calendar).toEqual({});
});
test(`returns [] for completeChallenges if showTimeLine is not true`, () => {
const userWithoutTimeLine = {
...user,
profileUI: { ...user.profileUI, showTimeLine: false }
};
expect(replacePrivateData(userWithoutTimeLine)).toMatchObject({
completedChallenges: []
});
});
test('omits certifications from completedChallenges if showCerts is not true', () => {
const userWithoutCerts = {
...user,
profileUI: { ...user.profileUI, showCerts: false }
};
expect(replacePrivateData(userWithoutCerts)).toMatchObject({
completedChallenges: [{ id: '123', completedDate: 123, files: [] }]
});
});
test('returns null for isDonating if showDonation is not true', () => {
const userWithoutDonation = {
...user,
profileUI: { ...user.profileUI, showDonation: false }
};
expect(replacePrivateData(userWithoutDonation)).toMatchObject({
isDonating: null
});
});
test('returns "" for joinDate if showAbout is not true', () => {
const userWithoutAbout = {
...user,
profileUI: { ...user.profileUI, showAbout: false }
};
expect(replacePrivateData(userWithoutAbout)).toMatchObject({
joinDate: ''
});
});
test(`returns "" for 'location' if showLocation is not true`, () => {
const userWithoutLocation = {
...user,
profileUI: { ...user.profileUI, showLocation: false }
};
expect(replacePrivateData(userWithoutLocation)).toMatchObject({
location: ''
});
});
test(`returns "" for 'name' if showName is not true`, () => {
const userWithoutName = {
...user,
profileUI: { ...user.profileUI, showName: false }
};
expect(replacePrivateData(userWithoutName)).toMatchObject({
name: ''
});
});
test('returns null for points if showPoints is not true', () => {
const userWithoutPoints = {
...user,
profileUI: { ...user.profileUI, showPoints: false }
};
expect(replacePrivateData(userWithoutPoints)).toMatchObject({
points: null
});
});
test('returns [] for portfolio if showPortfolio is not true', () => {
const userWithoutPortfolio = {
...user,
profileUI: { ...user.profileUI, showPortfolio: false }
};
expect(replacePrivateData(userWithoutPortfolio)).toMatchObject({
portfolio: []
});
});
test('returns the expected public user object if all showX flags are true', () => {
expect(replacePrivateData(user)).toEqual(
_.omit(user, ['id', 'profileUI'])
);
});
});
});
+183 -1
View File
@@ -1,4 +1,5 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { Portfolio } from '@prisma/client';
import { ObjectId } from 'mongodb';
import _ from 'lodash';
@@ -12,9 +13,11 @@ import {
normalizeProfileUI,
normalizeTwitter,
removeNulls,
normalizeSurveys
normalizeSurveys,
NormalizedChallenge
} from '../utils/normalize';
import {
Calendar,
getCalendar,
getPoints,
type ProgressTimestamp
@@ -23,6 +26,7 @@ import { encodeUserToken } from '../utils/tokens';
import { trimTags } from '../utils/validation';
import { generateReportEmail } from '../utils/email-templates';
import { createResetProperties } from '../utils/create-user';
import { challengeTypes } from '../../../shared/config/challenge-types';
// user flags that the api-server returns as false if they're missing in the
// user document. Since Prisma returns null for missing fields, we need to
@@ -542,6 +546,8 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
void res.code(500);
return { user: {}, result: '' };
}
// TODO: DRY this (the creation of the response body) and
// get-public-profile's response body creation.
const encodedToken = userToken
? encodeUserToken(userToken.id)
@@ -606,3 +612,179 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
done();
};
type ProfileUI = Partial<{
isLocked: boolean;
showAbout: boolean;
showCerts: boolean;
showDonation: boolean;
showHeatMap: boolean;
showLocation: boolean;
showName: boolean;
showPoints: boolean;
showPortfolio: boolean;
showTimeLine: boolean;
}>;
type RawUser = {
about: string;
completedChallenges: NormalizedChallenge[];
calendar: Calendar;
id: string;
isDonating: boolean;
joinDate: string;
location: string;
name: string;
points: number;
portfolio: Portfolio[];
profileUI: ProfileUI;
};
/**
* Creates an object with the properties that are shared with the public.
* @param user The raw user object.
* @returns The shared user object.
*/
export const replacePrivateData = (user: RawUser) => {
const {
showAbout,
showHeatMap,
showCerts,
showDonation,
showLocation,
showName,
showPoints,
showPortfolio,
showTimeLine
} = user.profileUI;
return {
about: showAbout ? user.about : '',
calendar: showHeatMap ? user.calendar : {},
completedChallenges: showTimeLine
? showCerts
? user.completedChallenges
: user.completedChallenges.filter(
c => c.challengeType !== challengeTypes.step
)
: [],
isDonating: showDonation ? user.isDonating : null,
joinDate: showAbout ? user.joinDate : '',
location: showLocation ? user.location : '',
name: showName ? user.name : '',
points: showPoints ? user.points : null,
portfolio: showPortfolio ? user.portfolio : []
};
};
/**
* Plugin containing public GET routes for user account management. They are kept
* separate because they do not require CSRF protection or authorization.
*
* @param fastify The Fastify instance.
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
* @param done Callback to signal that the logic has completed.
*/
export const userPublicGetRoutes: FastifyPluginCallbackTypebox = (
fastify,
_options,
done
) => {
fastify.get(
'/api/users/get-public-profile',
{
schema: schemas.getPublicProfile
},
async (req, reply) => {
// TODO(Post-MVP): look for duplicates unless we can make username unique in the db.
const user = await fastify.prisma.user.findFirst({
where: { username: req.query.username }
// TODO: only select desired fields, then stop 'omit'ing the undesired
// ones.
});
if (!user) {
void reply.code(404);
return reply.send({});
}
const flags = _.pick<typeof user, NullableFlag>(user, nullableFlags);
const rest = _.omit<typeof user, NullableFlag>(user, nullableFlags);
const publicUser = _.omit(rest, [
'currentChallengeId',
'email',
'emailVerified',
'sendQuincyEmail',
'theme',
// keyboardShortcuts is included in flags.
// 'keyboardShortcuts',
'acceptedPrivacyTerms',
'progressTimestamps',
'unsubscribeId',
'donationEmails',
'externalId',
'usernameDisplay',
'isBanned'
]);
const normalizedProfileUI = normalizeProfileUI(user.profileUI);
void reply.code(200);
if (normalizedProfileUI.isLocked) {
// TODO(Post-MVP): just return isLocked: true and either a null user
// or no user at all. (see other TODO in the else branch below)
return reply.send({
entities: {
user: {
[user.username]: {
isLocked: true,
profileUI: normalizedProfileUI,
username: user.username
}
}
},
result: user.username
});
} else {
const progressTimestamps = user.progressTimestamps as
| ProgressTimestamp[]
| null;
const sharedUser = replacePrivateData({
...user,
calendar: getCalendar(progressTimestamps),
completedChallenges: normalizeChallenges(user.completedChallenges),
location: user.location ?? '',
joinDate: new ObjectId(user.id).getTimestamp().toISOString(),
name: user.name ?? '',
points: getPoints(progressTimestamps),
profileUI: normalizedProfileUI
});
const returnedUser = {
...removeNulls(publicUser),
...normalizeFlags(flags),
...sharedUser,
profileUI: normalizedProfileUI,
// TODO: should this always be returned? Shouldn't some privacy
// setting control it? Same applies to website, githubProfile,
// and linkedin.
twitter: normalizeTwitter(user.twitter),
yearsTopContributor: user.yearsTopContributor
};
return reply.send({
// TODO(Post-MVP): just return a user object (i.e. returnedUser) and
// isLocked: false. The there should be no need for Type.Union in the
// schema. Alternatively, have the user object be nullable and don't
// bother with isLocked.
entities: {
user: { [user.username]: returnedUser }
},
result: user.username
});
}
}
);
done();
};
+8 -2
View File
@@ -8,13 +8,13 @@ const ajv = new Ajv({ strictTypes: false });
const isSchemaSecure = ajv.compile(secureSchema);
// These schemas will fail the tests, so can only be checked by hand.
const ignoredSchemas = ['getSessionUser'];
const ignoredSchemas = ['getSessionUser', 'getPublicProfile'];
describe('Schemas do not use obviously dangerous validation', () => {
Object.entries(schemas)
.filter(([schema]) => !ignoredSchemas.includes(schema))
.forEach(([name, schema]) => {
describe(`schema ${name} is okay`, () => {
describe(`schema ${name}`, () => {
if ('body' in schema) {
test('body is secure', () => {
expect(isSchemaSecure(schema.body)).toBeTruthy();
@@ -27,6 +27,12 @@ describe('Schemas do not use obviously dangerous validation', () => {
});
}
test('should use querystring instead of query', () => {
// if query is used then req.query is unknown, but if querystring is
// used then req.query has the expected type
expect('query' in schema).toBeFalsy();
});
if ('params' in schema) {
test('params is secure', () => {
expect(isSchemaSecure(schema.params)).toBeTruthy();
+1
View File
@@ -1,3 +1,4 @@
export { getPublicProfile } from './schemas/api/users/get-public-profile';
export { certSlug } from './schemas/certificate/cert-slug';
export { certificateVerify } from './schemas/certificate/certificate-verify';
export { backendChallengeCompleted } from './schemas/challenge/backend-challenge-completed';
@@ -0,0 +1,117 @@
import { Type } from '@fastify/type-provider-typebox';
import { profileUI, examResults, savedChallenge } from '../../types';
export const getPublicProfile = {
querystring: Type.Object({
username: Type.String({ minLength: 1 })
}),
response: {
200: Type.Object({
entities: Type.Object({
user: Type.Record(
Type.String(),
Type.Union([
Type.Object({
isLocked: Type.Boolean(),
profileUI,
username: Type.String()
}),
Type.Object({
about: Type.String(),
calendar: Type.Record(Type.Number(), Type.Literal(1)),
completedChallenges: Type.Array(
Type.Object({
id: Type.String(),
completedDate: Type.Number(),
solution: Type.Optional(Type.String()),
githubLink: Type.Optional(Type.String()),
challengeType: Type.Optional(Type.Number()),
files: Type.Array(
Type.Object({
contents: Type.String(),
key: Type.String(),
ext: Type.String(),
name: Type.String(),
path: Type.Optional(Type.String())
})
),
isManuallyApproved: Type.Optional(Type.Boolean())
})
),
completedExams: Type.Array(
Type.Object({
id: Type.String(),
completedDate: Type.Number(),
challengeType: Type.Optional(Type.Number()),
examResults
})
),
// TODO(Post-MVP): return completedSurveys? Presumably not, since why
// would this need to be public.
githubProfile: Type.Optional(Type.String()),
is2018DataVisCert: Type.Boolean(),
is2018FullStackCert: Type.Boolean(),
isApisMicroservicesCert: Type.Boolean(),
isBackEndCert: Type.Boolean(),
isCheater: Type.Boolean(),
isCollegeAlgebraPyCertV8: Type.Boolean(),
isDataAnalysisPyCertV7: Type.Boolean(),
isDataVisCert: Type.Boolean(),
// TODO(Post-MVP): isDonating should be boolean.
isDonating: Type.Union([Type.Boolean(), Type.Null()]),
isFoundationalCSharpCertV8: Type.Boolean(),
isFrontEndCert: Type.Boolean(),
isFrontEndLibsCert: Type.Boolean(),
isFullStackCert: Type.Boolean(),
isHonest: Type.Boolean(),
isInfosecCertV7: Type.Boolean(),
isInfosecQaCert: Type.Boolean(),
isJsAlgoDataStructCert: Type.Boolean(),
isJsAlgoDataStructCertV8: Type.Boolean(),
isMachineLearningPyCertV7: Type.Boolean(),
isQaCertV7: Type.Boolean(),
isRelationalDatabaseCertV8: Type.Boolean(),
isRespWebDesignCert: Type.Boolean(),
isSciCompPyCertV7: Type.Boolean(),
linkedin: Type.Optional(Type.String()),
location: Type.String(),
name: Type.String(),
partiallyCompletedChallenges: Type.Array(
Type.Object({
id: Type.String(),
completedDate: Type.Number()
})
),
picture: Type.String(),
// TODO(Post-MVP): points should be a number
points: Type.Union([Type.Number(), Type.Null()]),
portfolio: Type.Array(
Type.Object({
description: Type.String(),
id: Type.String(),
image: Type.String(),
title: Type.String(),
url: Type.String()
})
),
profileUI,
twitter: Type.Optional(Type.String()),
website: Type.Optional(Type.String()),
yearsTopContributor: Type.Array(Type.String()), // TODO(Post-MVP): convert to number?
joinDate: Type.String(),
savedChallenges: Type.Array(savedChallenge),
username: Type.String(),
msUsername: Type.Optional(Type.String())
})
])
)
}),
result: Type.String()
}),
// We can't simply have Type.Object({}), even though that's correct, because
// TypeScript will then accept all responses (since every object can be
// assigned to {})
400: Type.Object({ entities: Type.Optional(Type.Never()) }),
404: Type.Object({ entities: Type.Optional(Type.Never()) })
}
};
@@ -1,5 +1,5 @@
import { Type } from '@fastify/type-provider-typebox';
import { saveChallengeBody } from '../types';
import { savedChallenge } from '../types';
export const modernChallengeCompleted = {
body: Type.Object({
@@ -22,12 +22,7 @@ export const modernChallengeCompleted = {
completedDate: Type.Number(),
points: Type.Number(),
alreadyCompleted: Type.Boolean(),
savedChallenges: Type.Array(
Type.Intersect([
saveChallengeBody,
Type.Object({ lastSavedDate: Type.Number() })
])
)
savedChallenges: Type.Array(savedChallenge)
}),
400: Type.Object({
type: Type.Literal('error'),
+10 -8
View File
@@ -1,16 +1,18 @@
import { Type } from '@fastify/type-provider-typebox';
import { saveChallengeBody } from '../types';
import { file, savedChallenge } from '../types';
export const saveChallenge = {
body: saveChallengeBody,
body: Type.Object({
id: Type.String({
format: 'objectid',
maxLength: 24,
minLength: 24
}),
files: Type.Array(file)
}),
response: {
200: Type.Object({
savedChallenges: Type.Array(
Type.Intersect([
saveChallengeBody,
Type.Object({ lastSavedDate: Type.Number() })
])
)
savedChallenges: Type.Array(savedChallenge)
}),
403: Type.Literal('That challenge type is not saveable.'),
500: Type.Object({
+20 -7
View File
@@ -36,13 +36,13 @@ export const file = Type.Object({
history: Type.Array(Type.String())
});
export const saveChallengeBody = Type.Object({
id: Type.String({
format: 'objectid',
maxLength: 24,
minLength: 24
}),
files: Type.Array(file)
// This is only used for serialization, so should not use format. Reason being,
// the serializer's job is simply to create JSON strings, not to validate the
// data.
export const savedChallenge = Type.Object({
id: Type.String(),
files: Type.Array(file),
lastSavedDate: Type.Number()
});
export const examResults = Type.Object({
@@ -57,3 +57,16 @@ export const examResults = Type.Object({
export const surveyTitles = Type.Union([
Type.Literal('Foundational C# with Microsoft Survey')
]);
export const profileUI = Type.Object({
isLocked: Type.Optional(Type.Boolean()),
showAbout: Type.Optional(Type.Boolean()),
showCerts: Type.Optional(Type.Boolean()),
showDonation: Type.Optional(Type.Boolean()),
showHeatMap: Type.Optional(Type.Boolean()),
showLocation: Type.Optional(Type.Boolean()),
showName: Type.Optional(Type.Boolean()),
showPoints: Type.Optional(Type.Boolean()),
showPortfolio: Type.Optional(Type.Boolean()),
showTimeLine: Type.Optional(Type.Boolean())
});
+3 -19
View File
@@ -1,5 +1,5 @@
import { Type } from '@fastify/type-provider-typebox';
import { examResults, saveChallengeBody } from '../types';
import { examResults, profileUI, savedChallenge } from '../types';
export const getSessionUser = {
response: {
@@ -87,18 +87,7 @@ export const getSessionUser = {
url: Type.String()
})
),
profileUI: Type.Object({
isLocked: Type.Optional(Type.Boolean()),
showAbout: Type.Optional(Type.Boolean()),
showCerts: Type.Optional(Type.Boolean()),
showDonation: Type.Optional(Type.Boolean()),
showHeatMap: Type.Optional(Type.Boolean()),
showLocation: Type.Optional(Type.Boolean()),
showName: Type.Optional(Type.Boolean()),
showPoints: Type.Optional(Type.Boolean()),
showPortfolio: Type.Optional(Type.Boolean()),
showTimeLine: Type.Optional(Type.Boolean())
}),
profileUI: Type.Optional(profileUI),
sendQuincyEmail: Type.Boolean(),
theme: Type.String(),
twitter: Type.Optional(Type.String()),
@@ -106,12 +95,7 @@ export const getSessionUser = {
yearsTopContributor: Type.Array(Type.String()), // TODO(Post-MVP): convert to number?
isEmailVerified: Type.Boolean(),
joinDate: Type.String(),
savedChallenges: Type.Array(
Type.Intersect([
saveChallengeBody,
Type.Object({ lastSavedDate: Type.Number() })
])
),
savedChallenges: Type.Optional(Type.Array(savedChallenge)),
username: Type.String(),
userToken: Type.Optional(Type.String()),
completedSurveys: Type.Array(
+8 -9
View File
@@ -9,11 +9,16 @@ import {
import _ from 'lodash';
type NullToUndefined<T> = T extends null ? undefined : T;
type NullToFalse<T> = T extends null ? false : T;
type NoNullProperties<T> = {
[P in keyof T]: NullToUndefined<T[P]>;
};
type DefaultToFalse<T> = {
[P in keyof T]: NullToFalse<T[P]>;
};
/**
* Converts a Twitter handle or URL to a URL.
*
@@ -78,7 +83,7 @@ type NormalizedFile = {
path?: string;
};
type NormalizedChallenge = {
export type NormalizedChallenge = {
challengeType?: number;
completedDate: number;
files: NormalizedFile[];
@@ -142,11 +147,5 @@ export const normalizeSurveys = (
*/
export const normalizeFlags = <T extends Record<string, boolean | null>>(
flags: T
): { [K in keyof T]: boolean } => {
const out = {} as { [K in keyof T]: boolean };
for (const key in flags) {
const v = flags[key];
out[key] = typeof v === 'boolean' ? v : false;
}
return out;
};
): DefaultToFalse<T> =>
_.mapValues(flags, flag => flag ?? false) as DefaultToFalse<T>;
+3 -3
View File
@@ -1,5 +1,5 @@
export type ProgressTimestamp = number | { timestamp: number } | null;
export type Calendar = Record<number, 1>;
/**
* Converts a ProgressTimestamp array to a object with keys based on the timestamps.
*
@@ -8,8 +8,8 @@ export type ProgressTimestamp = number | { timestamp: number } | null;
*/
export const getCalendar = (
progressTimestamps: ProgressTimestamp[] | null
): Record<string, 1> => {
const calendar: Record<string, 1> = {};
): Calendar => {
const calendar: Calendar = {};
progressTimestamps?.forEach(progress => {
if (progress === null) return;