diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index e17167205d1..93c9ab81ae3 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -29,6 +29,19 @@ type CompletedChallenge { examResults ExamResults? // Undefined } +enum DailyCodingChallengeLanguage { + javascript + python +} + +type CompletedDailyCodingChallenge { + id String @db.ObjectId + /// Date in milliseconds since epoch + /// This is not a DateTime, because DateTime does not serialize directly to JSON + completedDate Int + languages DailyCodingChallengeLanguage[] +} + type PartiallyCompletedChallenge { id String completedDate Float @@ -77,77 +90,78 @@ type QuizAttempt { /// Corresponds to the `user` collection. model user { - id String @id @default(auto()) @map("_id") @db.ObjectId - about String - acceptedPrivacyTerms Boolean - completedChallenges CompletedChallenge[] - completedExams CompletedExam[] // Undefined - quizAttempts QuizAttempt[] // Undefined - currentChallengeId String? - donationEmails String[] // Undefined | String[] (only possible for built in Types like String) - email String - emailAuthLinkTTL DateTime? // Null | Undefined - emailVerified Boolean - emailVerifyTTL DateTime? // Null | Undefined - externalId String - githubProfile String? // Undefined - isApisMicroservicesCert Boolean? // Undefined - isBackEndCert Boolean? // Undefined - isBanned Boolean? // Undefined - isCheater Boolean? // Undefined - isDataAnalysisPyCertV7 Boolean? // Undefined - isDataVisCert Boolean? // Undefined - isDonating Boolean - isFoundationalCSharpCertV8 Boolean? // Undefined - isFrontEndCert Boolean? // Undefined - isFrontEndLibsCert Boolean? // Undefined - isFullStackCert Boolean? // Undefined - isHonest Boolean? - isInfosecCertV7 Boolean? // Undefined - isInfosecQaCert Boolean? // Undefined - isJsAlgoDataStructCert Boolean? // Undefined - isJsAlgoDataStructCertV8 Boolean? // Undefined - isMachineLearningPyCertV7 Boolean? // Undefined - isQaCertV7 Boolean? // Undefined - isRelationalDatabaseCertV8 Boolean? // Undefined - isRespWebDesignCert Boolean? // Undefined - isSciCompPyCertV7 Boolean? // Undefined - is2018DataVisCert Boolean? // Undefined - is2018FullStackCert Boolean? // Undefined - isCollegeAlgebraPyCertV8 Boolean? // Undefined - // isUpcomingPythonCertV8 Boolean? // Undefined. It is in the db but has never been used. - keyboardShortcuts Boolean? // Undefined - linkedin String? // Null | Undefined - location String? // Null - name String? // Null - needsModeration Boolean? // Undefined - newEmail String? // Null | Undefined - partiallyCompletedChallenges PartiallyCompletedChallenge[] // Undefined | PartiallyCompletedChallenge[] - password String? // Undefined - picture String - portfolio Portfolio[] - profileUI ProfileUI? // Undefined - progressTimestamps Json? // ProgressTimestamp[] | Null[] | Int64[] | Double[] - TODO: NORMALIZE + id String @id @default(auto()) @map("_id") @db.ObjectId + about String + acceptedPrivacyTerms Boolean + completedChallenges CompletedChallenge[] + completedDailyCodingChallenges CompletedDailyCodingChallenge[] + completedExams CompletedExam[] // Undefined + quizAttempts QuizAttempt[] // Undefined + currentChallengeId String? + donationEmails String[] // Undefined | String[] (only possible for built in Types like String) + email String + emailAuthLinkTTL DateTime? // Null | Undefined + emailVerified Boolean + emailVerifyTTL DateTime? // Null | Undefined + externalId String + githubProfile String? // Undefined + isApisMicroservicesCert Boolean? // Undefined + isBackEndCert Boolean? // Undefined + isBanned Boolean? // Undefined + isCheater Boolean? // Undefined + isDataAnalysisPyCertV7 Boolean? // Undefined + isDataVisCert Boolean? // Undefined + isDonating Boolean + isFoundationalCSharpCertV8 Boolean? // Undefined + isFrontEndCert Boolean? // Undefined + isFrontEndLibsCert Boolean? // Undefined + isFullStackCert Boolean? // Undefined + isHonest Boolean? + isInfosecCertV7 Boolean? // Undefined + isInfosecQaCert Boolean? // Undefined + isJsAlgoDataStructCert Boolean? // Undefined + isJsAlgoDataStructCertV8 Boolean? // Undefined + isMachineLearningPyCertV7 Boolean? // Undefined + isQaCertV7 Boolean? // Undefined + isRelationalDatabaseCertV8 Boolean? // Undefined + isRespWebDesignCert Boolean? // Undefined + isSciCompPyCertV7 Boolean? // Undefined + is2018DataVisCert Boolean? // Undefined + is2018FullStackCert Boolean? // Undefined + isCollegeAlgebraPyCertV8 Boolean? // Undefined + // isUpcomingPythonCertV8 Boolean? // Undefined. It is in the db but has never been used. + keyboardShortcuts Boolean? // Undefined + linkedin String? // Null | Undefined + location String? // Null + name String? // Null + needsModeration Boolean? // Undefined + newEmail String? // Null | Undefined + partiallyCompletedChallenges PartiallyCompletedChallenge[] // Undefined | PartiallyCompletedChallenge[] + password String? // Undefined + picture String + portfolio Portfolio[] + profileUI ProfileUI? // Undefined + progressTimestamps Json? // ProgressTimestamp[] | Null[] | Int64[] | Double[] - TODO: NORMALIZE /// A random number between 0 and 1. /// /// Valuable for selectively performing random logic. - rand Float? - savedChallenges SavedChallenge[] // Undefined | SavedChallenge[] - sendQuincyEmail Boolean - theme String? // Undefined - timezone String? // Undefined - twitter String? // Null | Undefined - unsubscribeId String + rand Float? + savedChallenges SavedChallenge[] // Undefined | SavedChallenge[] + sendQuincyEmail Boolean + theme String? // Undefined + timezone String? // Undefined + twitter String? // Null | Undefined + unsubscribeId String /// Used to track the number of times the user's record was written to. /// /// This has the main benefit of allowing concurrent ops to check for race conditions. - updateCount Int? @default(0) - username String // TODO(Post-MVP): make this unique - usernameDisplay String? // Undefined - verificationToken String? // Undefined - website String? // Undefined - yearsTopContributor String[] // Undefined | String[] - isClassroomAccount Boolean? // Undefined + updateCount Int? @default(0) + username String // TODO(Post-MVP): make this unique + usernameDisplay String? // Undefined + verificationToken String? // Undefined + website String? // Undefined + yearsTopContributor String[] // Undefined | String[] + isClassroomAccount Boolean? // Undefined // Relations examAttempts EnvExamAttempt[] diff --git a/api/src/plugins/__fixtures__/user.ts b/api/src/plugins/__fixtures__/user.ts index d41de813934..512e3d1364b 100644 --- a/api/src/plugins/__fixtures__/user.ts +++ b/api/src/plugins/__fixtures__/user.ts @@ -11,6 +11,7 @@ export const newUser = (email: string) => ({ about: '', acceptedPrivacyTerms: false, completedChallenges: [], + completedDailyCodingChallenges: [], completedExams: [], quizAttempts: [], currentChallengeId: '', diff --git a/api/src/routes/protected/challenge.test.ts b/api/src/routes/protected/challenge.test.ts index d76c22c48ee..3b6e36ab60c 100644 --- a/api/src/routes/protected/challenge.test.ts +++ b/api/src/routes/protected/challenge.test.ts @@ -5,6 +5,7 @@ const mockVerifyTrophyWithMicrosoft = jest.fn(); /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { omit } from 'lodash'; import { Static } from '@fastify/type-provider-typebox'; +import { DailyCodingChallengeLanguage } from '@prisma/client'; import { challengeTypes } from '../../../../shared/config/challenge-types'; import { @@ -146,6 +147,12 @@ const updatedMultiFileCertProjectBody = { ] }; +const dailyCodingChallengeId = '5900f36e1000cf542c50fe80'; +const dailyCodingChallengeBody = { + id: dailyCodingChallengeId, + language: DailyCodingChallengeLanguage.javascript +}; + describe('challengeRoutes', () => { setupServer(); describe('Authenticated user', () => { @@ -912,6 +919,204 @@ describe('challengeRoutes', () => { }); }); + describe('/daily-coding-challenge-completed', () => { + describe('validation', () => { + test('POST rejects requests without an id', async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, ...noIdReqBody } = dailyCodingChallengeBody; + const response = await superPost( + '/daily-coding-challenge-completed' + ).send(noIdReqBody); + + expect(response.body).toStrictEqual( + isValidChallengeCompletionErrorMsg + ); + expect(response.statusCode).toBe(400); + }); + + test('POST rejects requests without a language', async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { language, ...noLanguageReqBody } = dailyCodingChallengeBody; + const response = await superPost( + '/daily-coding-challenge-completed' + ).send(noLanguageReqBody); + + expect(response.body).toStrictEqual( + isValidChallengeCompletionErrorMsg + ); + expect(response.statusCode).toBe(400); + }); + + test('POST rejects requests without valid ObjectIDs', async () => { + const response = await superPost( + '/daily-coding-challenge-completed' + ).send({ + ...dailyCodingChallengeBody, + id: 'not-a-valid-id' + }); + + expect(response.body).toStrictEqual( + isValidChallengeCompletionErrorMsg + ); + expect(response.statusCode).toBe(400); + }); + + test('POST rejects requests without valid coding language', async () => { + const response = await superPost( + '/daily-coding-challenge-completed' + ).send({ + ...dailyCodingChallengeBody, + language: 'not-a-valid-language' + }); + + expect(response.body).toStrictEqual( + isValidChallengeCompletionErrorMsg + ); + expect(response.statusCode).toBe(400); + }); + }); + + describe('handling', () => { + afterEach(async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { email: 'foo@bar.com' }, + data: { + completedDailyCodingChallenges: [], + progressTimestamps: [] + } + }); + }); + + test('POST correctly handles multiple requests', async () => { + const now = Date.now(); + + const res1 = await superPost( + '/daily-coding-challenge-completed' + ).send(dailyCodingChallengeBody); + + const user1 = await fastifyTestInstance.prisma.user.findFirstOrThrow({ + where: { email: 'foo@bar.com' } + }); + + const completedDate = + user1.completedDailyCodingChallenges[0]?.completedDate; + + // should have correct completedDate + expect(completedDate).toBeGreaterThanOrEqual(now); + expect(completedDate).toBeLessThanOrEqual(now + 1000); + + expect(user1).toMatchObject({ + // should add completedDailyCodingChallenge to database with correct info + completedDailyCodingChallenges: [ + { + id: dailyCodingChallengeId, + completedDate, + languages: [DailyCodingChallengeLanguage.javascript] + } + ], + // should add to progressTimestamps + progressTimestamps: [completedDate] + }); + + // should have correct response + expect(res1.statusCode).toBe(200); + expect(res1.body).toStrictEqual({ + alreadyCompleted: false, + points: 1, + completedDate, + completedDailyCodingChallenges: [ + { + id: dailyCodingChallengeId, + completedDate, + languages: [DailyCodingChallengeLanguage.javascript] + } + ] + }); + + const res2 = await superPost( + '/daily-coding-challenge-completed' + ).send(dailyCodingChallengeBody); + + const user2 = await fastifyTestInstance.prisma.user.findFirstOrThrow({ + where: { email: 'foo@bar.com' } + }); + + // should not add 'javascript' again, should not update completedDate + expect(user2).toMatchObject({ + completedDailyCodingChallenges: [ + { + id: dailyCodingChallengeId, + completedDate, + languages: [DailyCodingChallengeLanguage.javascript] + } + ], + // should not add to progressTimestamps + progressTimestamps: [completedDate] + }); + + // should have correct response + expect(res2.statusCode).toBe(200); + expect(res2.body).toStrictEqual({ + alreadyCompleted: true, + points: 1, + completedDate, + completedDailyCodingChallenges: [ + { + id: dailyCodingChallengeId, + completedDate, + languages: [DailyCodingChallengeLanguage.javascript] + } + ] + }); + + const res3 = await superPost( + '/daily-coding-challenge-completed' + ).send({ + ...dailyCodingChallengeBody, + language: 'python' + }); + + const user3 = await fastifyTestInstance.prisma.user.findFirstOrThrow({ + where: { email: 'foo@bar.com' } + }); + + // should add 'python' to languages + should not update completedDate + expect(user3).toMatchObject({ + completedDailyCodingChallenges: [ + { + id: dailyCodingChallengeId, + completedDate, + languages: [ + DailyCodingChallengeLanguage.javascript, + DailyCodingChallengeLanguage.python + ] + } + ], + // should not add to progressTimestamps + progressTimestamps: [completedDate] + }); + + // should have correct response + expect(res3.statusCode).toBe(200); + expect(res3.body).toStrictEqual({ + alreadyCompleted: true, + points: 1, + completedDate, + completedDailyCodingChallenges: [ + { + id: dailyCodingChallengeId, + completedDate, + languages: [ + DailyCodingChallengeLanguage.javascript, + DailyCodingChallengeLanguage.python + ] + } + ] + }); + }); + }); + }); + describe('POST /save-challenge', () => { describe('validation', () => { test('returns 400 status for unsavable challenges', async () => { @@ -1848,6 +2053,7 @@ describe('challengeRoutes', () => { { path: '/project-completed', method: 'POST' }, { path: '/backend-challenge-completed', method: 'POST' }, { path: '/modern-challenge-completed', method: 'POST' }, + { path: '/daily-coding-challenge-completed', method: 'POST' }, { path: '/save-challenge', method: 'POST' }, { path: '/exam/647e22d18acb466c97ccbef8', method: 'GET' }, { path: '/ms-trophy-challenge-completed', method: 'POST' }, diff --git a/api/src/routes/protected/challenge.ts b/api/src/routes/protected/challenge.ts index cba143eb682..b4e17d4c549 100644 --- a/api/src/routes/protected/challenge.ts +++ b/api/src/routes/protected/challenge.ts @@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken'; import { uniqBy, matches } from 'lodash'; import { CompletedExam, ExamResults } from '@prisma/client'; import isURL from 'validator/lib/isURL'; +import type { FastifyInstance, FastifyReply } from 'fastify'; import { challengeTypes } from '../../../../shared/config/challenge-types'; import * as schemas from '../../schemas'; @@ -33,6 +34,7 @@ import { canSubmitCodeRoadCertProject, verifyTrophyWithMicrosoft } from '../helpers/challenge-helpers'; +import { UpdateReqType } from '../../utils'; interface JwtPayload { userToken: string; @@ -415,6 +417,26 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( } ); + fastify.post( + '/daily-coding-challenge-completed', + { + schema: schemas.dailyCodingChallengeCompleted, + errorHandler(error, req, reply) { + const logger = fastify.log.child({ req }); + if (error.validation) { + logger.warn({ validationError: error.validation }); + void reply.code(400); + void reply.send({ + type: 'error', + message: 'That does not appear to be a valid challenge submission.' + }); + fastify.errorHandler(error, req, reply); + } + } + }, + postDailyCodingChallengeCompleted + ); + fastify.post( '/save-challenge', { @@ -949,3 +971,103 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( done(); }; + +async function postDailyCodingChallengeCompleted( + this: FastifyInstance, + req: UpdateReqType, + reply: FastifyReply +) { + const logger = this.log.child({ req }); + logger.info(`User ${req.user?.id} submitted a daily coding challenge`); + + const { id, language } = req.body; + + const user = await this.prisma.user.findUniqueOrThrow({ + where: { id: req.user?.id }, + select: { + completedDailyCodingChallenges: true, + progressTimestamps: true + } + }); + + const { completedDailyCodingChallenges, progressTimestamps = [] } = user; + + const points = getPoints(progressTimestamps as ProgressTimestamp[]); + const oldCompletedChallenge = completedDailyCodingChallenges.find( + c => c.id === id + ); + + const alreadyCompleted = !!oldCompletedChallenge; + const languageAlreadyCompleted = + oldCompletedChallenge?.languages.includes(language); + + if (alreadyCompleted) { + const { completedDate, languages } = oldCompletedChallenge; + + if (languageAlreadyCompleted) { + // alreadyCompleted && languageAlreadyCompleted, no need to change anything in the database + return reply.send({ + alreadyCompleted, + points, + completedDate, + completedDailyCodingChallenges + }); + } else { + // alreadyCompleted && !languageAlreadyCompleted, add the language to the record + const { completedDailyCodingChallenges } = await this.prisma.user.update({ + where: { id: req.user?.id }, + select: { + completedDailyCodingChallenges: true + }, + data: { + completedDailyCodingChallenges: { + updateMany: { + where: { id }, + data: { + languages: [...new Set([...languages, language])] + } + } + } + } + }); + return reply.send({ + alreadyCompleted, + points, + completedDate, + completedDailyCodingChallenges + }); + } + } else { + // !alreadyCompleted, add new record for completed challenge + const newCompletedDate = Date.now(); + + const newCompletedChallenge = { + id, + completedDate: newCompletedDate, + languages: [language] + }; + + const newCompletedChallenges = [ + ...completedDailyCodingChallenges, + newCompletedChallenge + ]; + + const newProgressTimestamps = Array.isArray(progressTimestamps) + ? [...progressTimestamps, newCompletedDate] + : [newCompletedDate]; + + await this.prisma.user.update({ + where: { id: req.user?.id }, + data: { + completedDailyCodingChallenges: newCompletedChallenges, + progressTimestamps: newProgressTimestamps + } + }); + return reply.send({ + alreadyCompleted, + points: points + 1, + completedDate: newCompletedDate, + completedDailyCodingChallenges: newCompletedChallenges + }); + } +} diff --git a/api/src/routes/protected/user.test.ts b/api/src/routes/protected/user.test.ts index 0ee036d8e12..2fe1c792256 100644 --- a/api/src/routes/protected/user.test.ts +++ b/api/src/routes/protected/user.test.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import jwt, { JwtPayload } from 'jsonwebtoken'; -import type { Prisma } from '@prisma/client'; +import { DailyCodingChallengeLanguage, type Prisma } from '@prisma/client'; import { ObjectId } from 'mongodb'; import _ from 'lodash'; @@ -78,6 +78,16 @@ const testUserData: Prisma.userCreateInput = { } } ], + completedDailyCodingChallenges: [ + { + id: '5900f36e1000cf542c50fe80', + completedDate: 1742941672524, + languages: [ + DailyCodingChallengeLanguage.python, + DailyCodingChallengeLanguage.javascript + ] + } + ], partiallyCompletedChallenges: [{ id: '123', completedDate: 123 }], completedExams: [], quizAttempts: [ @@ -218,6 +228,16 @@ const publicUserData = { } } ], + completedDailyCodingChallenges: [ + { + id: '5900f36e1000cf542c50fe80', + completedDate: 1742941672524, + languages: [ + DailyCodingChallengeLanguage.python, + DailyCodingChallengeLanguage.javascript + ] + } + ], completedExams: testUserData.completedExams, completedSurveys: [], // TODO: add surveys quizAttempts: testUserData.quizAttempts, @@ -290,6 +310,7 @@ const baseProgressData = { isRelationalDatabaseCertV8: false, isCollegeAlgebraPyCertV8: false, completedChallenges: [], + completedDailyCodingChallenges: [], completedExams: [], savedChallenges: [], partiallyCompletedChallenges: [], @@ -735,6 +756,7 @@ describe('userRoutes', () => { // missing in the user document. currentChallengeId: '', completedChallenges: [], + completedDailyCodingChallenges: [], completedExams: [], completedSurveys: [], partiallyCompletedChallenges: [], diff --git a/api/src/routes/protected/user.ts b/api/src/routes/protected/user.ts index 3c6898a3be5..85e1fb9e89d 100644 --- a/api/src/routes/protected/user.ts +++ b/api/src/routes/protected/user.ts @@ -505,6 +505,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( about: true, acceptedPrivacyTerms: true, completedChallenges: true, + completedDailyCodingChallenges: true, completedExams: true, currentChallengeId: true, quizAttempts: true, @@ -589,6 +590,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( username, usernameDisplay, completedChallenges, + completedDailyCodingChallenges, progressTimestamps, twitter, profileUI, @@ -607,6 +609,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = ( currentChallengeId: currentChallengeId ?? '', completedChallenges: normalizeChallenges(completedChallenges), completedChallengeCount: completedChallenges.length, + completedDailyCodingChallenges, // This assertion is necessary until the database is normalized. calendar: getCalendar( progressTimestamps as ProgressTimestamp[] | null diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 6d9ef398cd0..abac46c8ce8 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -6,6 +6,7 @@ export { backendChallengeCompleted } from './schemas/challenge/backend-challenge export { coderoadChallengeCompleted } from './schemas/challenge/coderoad-challenge-completed'; export { exam } from './schemas/challenge/exam'; export { examChallengeCompleted } from './schemas/challenge/exam-challenge-completed'; +export { dailyCodingChallengeCompleted } from './schemas/challenge/daily-coding-challenge-completed'; export { modernChallengeCompleted } from './schemas/challenge/modern-challenge-completed'; export { msTrophyChallengeCompleted } from './schemas/challenge/ms-trophy-challenge-completed'; export { projectCompleted } from './schemas/challenge/project-completed'; diff --git a/api/src/schemas/challenge/daily-coding-challenge-completed.ts b/api/src/schemas/challenge/daily-coding-challenge-completed.ts new file mode 100644 index 00000000000..6d6ef9bbe98 --- /dev/null +++ b/api/src/schemas/challenge/daily-coding-challenge-completed.ts @@ -0,0 +1,33 @@ +import { Type } from '@fastify/type-provider-typebox'; +import { DailyCodingChallengeLanguage } from '@prisma/client'; + +const languages = Object.values(DailyCodingChallengeLanguage).map(k => + Type.Literal(k) +); + +export const dailyCodingChallengeCompleted = { + body: Type.Object({ + id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }), + language: Type.Union(languages) + }), + response: { + 200: Type.Object({ + completedDate: Type.Number(), + points: Type.Number(), + alreadyCompleted: Type.Boolean(), + completedDailyCodingChallenges: Type.Array( + Type.Object({ + id: Type.String(), + completedDate: Type.Number(), + languages: Type.Array(Type.Union(languages)) + }) + ) + }), + 400: Type.Object({ + type: Type.Literal('error'), + message: Type.Literal( + 'That does not appear to be a valid challenge submission.' + ) + }) + } +}; diff --git a/api/src/schemas/user/get-session-user.ts b/api/src/schemas/user/get-session-user.ts index fcab27ca7cf..6d9c34bb72b 100644 --- a/api/src/schemas/user/get-session-user.ts +++ b/api/src/schemas/user/get-session-user.ts @@ -1,6 +1,11 @@ import { Type } from '@fastify/type-provider-typebox'; +import { DailyCodingChallengeLanguage } from '@prisma/client'; import { examResults, profileUI, savedChallenge } from '../types'; +const languages = Object.values(DailyCodingChallengeLanguage).map(k => + Type.Literal(k) +); + export const getSessionUser = { response: { 200: Type.Object({ @@ -53,6 +58,13 @@ export const getSessionUser = { }) ), completedChallengeCount: Type.Number(), + completedDailyCodingChallenges: Type.Array( + Type.Object({ + id: Type.String(), + completedDate: Type.Number(), + languages: Type.Array(Type.Union(languages)) + }) + ), currentChallengeId: Type.String(), email: Type.String(), emailVerified: Type.Boolean(),