diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 93c9ab81ae3..00ad0dd1873 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -20,7 +20,7 @@ type File { type CompletedChallenge { challengeType Int? @db.Int // Null | Undefined - completedDate Float // TODO(Post-MVP): Change to DateTime + completedDate Json // DateTime | Float, but not, as far as we know, Null files File[] githubLink String? // Undefined id String diff --git a/api/src/routes/helpers/certificate-utils.ts b/api/src/routes/helpers/certificate-utils.ts index bb4a3c8c378..01b14f71f7a 100644 --- a/api/src/routes/helpers/certificate-utils.ts +++ b/api/src/routes/helpers/certificate-utils.ts @@ -1,7 +1,9 @@ +import { Prisma } from '@prisma/client'; import { certSlugTypeMap, certIds } from '../../../../shared/config/certification-settings'; +import { normalizeDate } from '../../utils/normalize'; const { legacyInfosecQaId, @@ -41,12 +43,16 @@ export function isKnownCertSlug( * @returns The latest certification date or the completed date if no certification is found. */ export function getFallbackFullStackDate( - completedChallenges: { id: string; completedDate: number }[], - completedDate: number -) { + completedChallenges: { id: string; completedDate: Prisma.JsonValue }[], + completedDate: Prisma.JsonValue +): number { const latestCertDate = completedChallenges .filter(chal => fullStackCertificateIds.includes(chal.id)) + .map(chal => ({ + ...chal, + completedDate: normalizeDate(chal.completedDate) + })) .sort((a, b) => b.completedDate - a.completedDate)[0]?.completedDate; - return latestCertDate ?? completedDate; + return latestCertDate ?? normalizeDate(completedDate); } diff --git a/api/src/routes/protected/challenge.ts b/api/src/routes/protected/challenge.ts index b4e17d4c549..751cd7a081e 100644 --- a/api/src/routes/protected/challenge.ts +++ b/api/src/routes/protected/challenge.ts @@ -35,6 +35,7 @@ import { verifyTrophyWithMicrosoft } from '../helpers/challenge-helpers'; import { UpdateReqType } from '../../utils'; +import { normalizeDate } from '../../utils/normalize'; interface JwtPayload { userToken: string; @@ -263,13 +264,12 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( 'User tried to submit a codeRoad cert project before completing the required challenges' ); void reply.code(403); - return { + return reply.send({ type: 'error', message: 'You have to complete the project before you can submit a URL.' - } as const; + }); } - const challenge = { challengeType, solution, @@ -287,13 +287,13 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( challenge ); - return { + reply.send({ alreadyCompleted, // TODO(Post-MVP): audit the client and remove this if the client does // not use it. - completedDate, + completedDate: normalizeDate(completedDate), points: alreadyCompleted ? points : points + 1 - }; + }); } ); @@ -685,11 +685,11 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( completedChallenge ); - return { + reply.send({ alreadyCompleted, points: getPoints(progressTimestamps) + (alreadyCompleted ? 0 : 1), - completedDate - }; + completedDate: normalizeDate(completedDate) + }); } catch (error) { logger.error(error, 'Error submitting Microsoft trophy challenge'); fastify.Sentry.captureException(error); @@ -808,7 +808,10 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( } const newCompletedChallenges: CompletedChallenge[] = - completedChallenges; + completedChallenges.map(c => { + const { completedDate, ...rest } = c; + return { completedDate: normalizeDate(completedDate), ...rest }; + }); const newCompletedExams: CompletedExam[] = completedExams; const newProgressTimeStamps = progressTimestamps as ProgressTimestamp[]; const completedDate = Date.now(); diff --git a/api/src/routes/public/certificate.ts b/api/src/routes/public/certificate.ts index 4e1360bb95c..f9027ec9d43 100644 --- a/api/src/routes/public/certificate.ts +++ b/api/src/routes/public/certificate.ts @@ -13,6 +13,7 @@ import { getFallbackFullStackDate, isKnownCertSlug } from '../helpers/certificate-utils'; +import { normalizeDate } from '../../utils/normalize'; /** * Plugin for the unprotected certificate endpoints. @@ -228,7 +229,7 @@ export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = ( certSlug, certTitle, username, - date: completedDate, + date: normalizeDate(completedDate), completionTime }); } @@ -239,7 +240,7 @@ export const unprotectedCertificateRoutes: FastifyPluginCallbackTypebox = ( certTitle, username, name, - date: completedDate, + date: normalizeDate(completedDate), completionTime }); } diff --git a/api/src/utils/common-challenge-functions.ts b/api/src/utils/common-challenge-functions.ts index a136298a019..2cf46fa0112 100644 --- a/api/src/utils/common-challenge-functions.ts +++ b/api/src/utils/common-challenge-functions.ts @@ -3,6 +3,7 @@ import { FastifyInstance } from 'fastify'; import { omit, pick } from 'lodash'; import { challengeTypes } from '../../../shared/config/challenge-types'; import { getChallenges } from './get-challenges'; +import { normalizeDate } from './normalize'; export const jsCertProjectIds = [ 'aaa48de84e1ecc7c742e1124', @@ -149,7 +150,8 @@ export async function updateUserChallengeData( 'path', 'ext' ]) as CompletedChallengeFile - ) + ), + completedDate: normalizeDate(_completedChallenge.completedDate) }; } else { completedChallenge = omit(_completedChallenge, ['files']); @@ -171,7 +173,7 @@ export async function updateUserChallengeData( const finalChallenge = alreadyCompleted ? { ...completedChallenge, - completedDate: oldChallenge.completedDate + completedDate: normalizeDate(oldChallenge.completedDate) } : completedChallenge; @@ -180,7 +182,11 @@ export async function updateUserChallengeData( // check and update some property of the user record such that the same update // can't be applied twice. const userCompletedChallenges = alreadyCompleted - ? completedChallenges.map(x => (x.id === challengeId ? finalChallenge : x)) + ? completedChallenges.map(x => + x.id === challengeId + ? finalChallenge + : { ...x, completedDate: normalizeDate(x.completedDate) } + ) : { push: finalChallenge }; // We can't use push, because progressTimestamps is a JSON blob and, until diff --git a/api/src/utils/normalize.test.ts b/api/src/utils/normalize.test.ts index 144febfa411..94a38321f6e 100644 --- a/api/src/utils/normalize.test.ts +++ b/api/src/utils/normalize.test.ts @@ -2,7 +2,8 @@ import { normalizeTwitter, normalizeProfileUI, normalizeChallenges, - normalizeFlags + normalizeFlags, + normalizeDate } from './normalize'; describe('normalize', () => { @@ -156,4 +157,22 @@ describe('normalize', () => { }); }); }); + + describe('validateDate', () => { + it('should return the date as a number', () => { + expect(normalizeDate(1)).toEqual(1); + expect(normalizeDate({ $date: '2023-10-01T00:00:00Z' })).toEqual( + 1696118400000 + ); + }); + + it('should throw an error if the date is not in the expected shape', () => { + expect(() => normalizeDate('2023-10-01T00:00:00Z')).toThrow( + 'Unexpected date value: "2023-10-01T00:00:00Z"' + ); + expect(() => normalizeDate({ date: '123' })).toThrow( + 'Unexpected date value: {"date":"123"}' + ); + }); + }); }); diff --git a/api/src/utils/normalize.ts b/api/src/utils/normalize.ts index bc2df169576..4bd341dcddb 100644 --- a/api/src/utils/normalize.ts +++ b/api/src/utils/normalize.ts @@ -1,10 +1,11 @@ /* This module's job is to parse the database output and prepare it for serialization */ -import { +import type { ProfileUI, CompletedChallenge, ExamResults, - type Survey + Survey, + Prisma } from '@prisma/client'; import _ from 'lodash'; @@ -39,6 +40,27 @@ export const normalizeTwitter = ( return url ?? handleOrUrl; }; +/** + * Normalizes a date value to a timestamp number. + * + * @param date An object with a $date string or a number. + * @returns The date as a timestamp number. + */ +export const normalizeDate = (date?: Prisma.JsonValue): number => { + if (typeof date === 'number') { + return date; + } else if ( + date && + typeof date === 'object' && + '$date' in date && + typeof date.$date === 'string' + ) { + return new Date(date.$date).getTime(); + } else { + throw Error('Unexpected date value: ' + JSON.stringify(date)); + } +}; + /** * Ensure that the user's profile UI settings are valid. * @@ -114,7 +136,15 @@ export const normalizeChallenges = ( return { ...rest, files: noNullFiles }; }); - return noNullPath; + const withNumberDates = noNullPath.map(challenge => { + const { completedDate, ...rest } = challenge; + return { + ...rest, + completedDate: normalizeDate(completedDate) + }; + }); + + return withNumberDates; }; type NormalizedSurvey = {