feat(api): add endpoint for submitting daily coding challenges (#59465)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Tom
2025-05-12 10:26:32 -05:00
committed by GitHub
parent 40f89512a2
commit f908548246
9 changed files with 480 additions and 66 deletions
+79 -65
View File
@@ -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[]
+1
View File
@@ -11,6 +11,7 @@ export const newUser = (email: string) => ({
about: '',
acceptedPrivacyTerms: false,
completedChallenges: [],
completedDailyCodingChallenges: [],
completedExams: [],
quizAttempts: [],
currentChallengeId: '',
+206
View File
@@ -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' },
+122
View File
@@ -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<typeof schemas.dailyCodingChallengeCompleted>,
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
});
}
}
+23 -1
View File
@@ -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: [],
+3
View File
@@ -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
+1
View File
@@ -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';
@@ -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.'
)
})
}
};
+12
View File
@@ -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(),