diff --git a/api/src/routes/user.test.ts b/api/src/routes/user.test.ts index 633a2be339c9b0c..b400f6a1cc86a0d 100644 --- a/api/src/routes/user.test.ts +++ b/api/src/routes/user.test.ts @@ -79,7 +79,9 @@ const testUserData: Prisma.userCreateInput = { ], savedChallenges: [ { - id: 'abc123', + // TODO: figure out why, when this was a short id, was it that only the get-public-profile + // tests failed, not the get-session-user tests. + id: 'a6b0bb188d873cb2c8729495', lastSavedDate: 123, files: [ { @@ -1241,9 +1243,9 @@ Thanks and regards, // TODO(Post-MVP, maybe): return completedSurveys? ..._.omit(publicUserData, 'completedSurveys'), username: publicUsername, - id: testUser.id, joinDate: new ObjectId(testUser.id).getTimestamp().toISOString(), - profileUI: unlockedUserProfileUI + profileUI: unlockedUserProfileUI, + isDonating: null }; expect(response.body).toStrictEqual({ diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts index 65e54938922fb36..6c5b018595a8def 100644 --- a/api/src/routes/user.ts +++ b/api/src/routes/user.ts @@ -1,6 +1,6 @@ import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; import { ObjectId } from 'mongodb'; -import { omit } from 'lodash'; +import _ from 'lodash'; import * as schemas from '../schemas'; // Loopback creates a 64 character string for the user id, this customizes @@ -8,6 +8,7 @@ import * as schemas from '../schemas'; import { customNanoid } from '../utils/ids'; import { normalizeChallenges, + normalizeFlags, normalizeProfileUI, normalizeTwitter, removeNulls, @@ -648,27 +649,29 @@ export const userPublicGetRoutes: FastifyPluginCallbackTypebox = ( // ones. }); - console.log('user', user); - if (!user) { void reply.code(404); return reply.send({}); } - const publicUser = omit(user, [ + const flags = _.pick(user, nullableFlags); + const rest = _.omit(user, nullableFlags); + + const publicUser = _.omit(rest, [ 'currentChallengeId', 'email', 'emailVerified', 'sendQuincyEmail', 'theme', - 'keyboardShortcuts', + // keyboardShortcuts is included in flags. + // 'keyboardShortcuts', 'acceptedPrivacyTerms', 'progressTimestamps', 'unsubscribeId', 'donationEmails', 'externalId', 'usernameDisplay', - 'isBanned' // TODO: should this be omitted? + 'isBanned' ]); const normalizedProfileUI = normalizeProfileUI(user.profileUI); @@ -693,47 +696,47 @@ export const userPublicGetRoutes: FastifyPluginCallbackTypebox = ( const normalizedChallenges = normalizedProfileUI.showTimeLine ? normalizeChallenges(user.completedChallenges) : []; + + const returnedUser = { + [user.username]: { + ...removeNulls(publicUser), + ...normalizeFlags(flags), + about: normalizedProfileUI.showAbout ? user.about : '', + calendar: normalizedProfileUI.showHeatMap + ? getCalendar( + user.progressTimestamps as ProgressTimestamp[] | null + ) + : {}, + completedChallenges: normalizedProfileUI.showCerts + ? normalizedChallenges + : normalizedChallenges.filter( + ({ challengeType }) => challengeType !== challengeTypes.step // AKA certifications + ), + isDonating: + normalizedProfileUI.showDonation && user.isDonating ? true : null, + joinDate: normalizedProfileUI.showAbout + ? new ObjectId(user.id).getTimestamp().toISOString() + : '', + location: + normalizedProfileUI.showLocation && user.location + ? user.location + : '', + name: normalizedProfileUI.showName && user.name ? user.name : '', + points: normalizedProfileUI.showPoints + ? getPoints(user.progressTimestamps as ProgressTimestamp[] | null) + : 0, + portfolio: normalizedProfileUI.showPortfolio ? user.portfolio : [], + 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({ entities: { - user: { - [user.username]: { - ...removeNulls(publicUser), - about: normalizedProfileUI.showAbout ? user.about : '', - calendar: normalizedProfileUI.showHeatMap - ? getCalendar( - user.progressTimestamps as ProgressTimestamp[] | null - ) - : {}, - completedChallenges: normalizedProfileUI.showCerts - ? normalizedChallenges - : normalizedChallenges.filter( - ({ challengeType }) => - challengeType !== challengeTypes.step // AKA certifications - ), - isDonating: normalizedProfileUI.showDonation - ? user.isDonating - : null, - joinDate: normalizedProfileUI.showAbout - ? new ObjectId(user.id).getTimestamp().toISOString() - : '', - location: normalizedProfileUI.showLocation ? user.location : '', - name: normalizedProfileUI.showName ? user.name : '', - points: normalizedProfileUI.showPoints - ? getPoints( - user.progressTimestamps as ProgressTimestamp[] | null - ) - : 0, - portfolio: normalizedProfileUI.showPortfolio - ? user.portfolio - : [], - 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 - } - } + user: returnedUser }, result: user.username }); diff --git a/api/src/schemas/api/users/get-public-profile.ts b/api/src/schemas/api/users/get-public-profile.ts index 6886a2325adeaaa..e1c91272c014d00 100644 --- a/api/src/schemas/api/users/get-public-profile.ts +++ b/api/src/schemas/api/users/get-public-profile.ts @@ -1,28 +1,118 @@ import { Type } from '@fastify/type-provider-typebox'; -import { profileUI } from '../../types'; +import { profileUI, saveChallengeBody, examResults } from '../../types'; export const getPublicProfile = { querystring: Type.Object({ username: Type.String({ minLength: 1 }) }), response: { - 200: Type.Union([ - Type.Object({ - entities: Type.Object({ - user: Type.Record( - Type.String(), + 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): format as url/uri? + // 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( + Type.Intersect([ + saveChallengeBody, + Type.Object({ lastSavedDate: Type.Number() }) + ]) + ), + username: Type.String(), + msUsername: Type.Optional(Type.String()) }) - ) - }), - result: Type.String() + ]) + ) }), - // TODO: replace Any with real type. - Type.Object({ entities: Type.Any(), result: 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 {})