mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-10-25 10:47:00 +08:00
843 lines
25 KiB
TypeScript
843 lines
25 KiB
TypeScript
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
|
import jwt from 'jsonwebtoken';
|
|
import { uniqBy } from 'lodash';
|
|
import { CompletedExam, ExamResults } from '@prisma/client';
|
|
import isURL from 'validator/lib/isURL';
|
|
|
|
import { challengeTypes } from '../../../shared/config/challenge-types';
|
|
import { schemas } from '../schemas';
|
|
import {
|
|
jsCertProjectIds,
|
|
multifileCertProjectIds,
|
|
multifilePythonCertProjectIds,
|
|
updateUserChallengeData,
|
|
type CompletedChallenge,
|
|
saveUserChallengeData,
|
|
msTrophyChallenges
|
|
} from '../utils/common-challenge-functions';
|
|
import { JWT_SECRET } from '../utils/env';
|
|
import {
|
|
formatCoderoadChallengeCompletedValidation,
|
|
formatProjectCompletedValidation
|
|
} from '../utils/error-formatting';
|
|
import { getChallenges } from '../utils/get-challenges';
|
|
import { ProgressTimestamp, getPoints } from '../utils/progress';
|
|
import {
|
|
validateExamFromDbSchema,
|
|
validateGeneratedExamSchema,
|
|
validateUserCompletedExamSchema,
|
|
validateExamResultsSchema
|
|
} from '../utils/exam-schemas';
|
|
import { generateRandomExam, createExamResults } from '../utils/exam';
|
|
import {
|
|
canSubmitCodeRoadCertProject,
|
|
createProject,
|
|
updateProject,
|
|
verifyTrophyWithMicrosoft
|
|
} from './helpers/challenge-helpers';
|
|
|
|
interface JwtPayload {
|
|
userToken: string;
|
|
}
|
|
|
|
/**
|
|
* Plugin for the challenge submission endpoints.
|
|
*
|
|
* @param fastify The Fastify instance.
|
|
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
|
* @param done The callback to signal that the plugin is ready.
|
|
*/
|
|
export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
|
fastify,
|
|
_options,
|
|
done
|
|
) => {
|
|
const challenges = getChallenges();
|
|
|
|
// @ts-expect-error - @fastify/csrf-protection needs to update their types
|
|
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
fastify.addHook('onRequest', fastify.csrfProtection);
|
|
fastify.addHook('onRequest', fastify.authorize);
|
|
|
|
fastify.post(
|
|
'/coderoad-challenge-completed',
|
|
{
|
|
schema: schemas.coderoadChallengeCompleted,
|
|
errorHandler(error, request, reply) {
|
|
if (error.validation) {
|
|
void reply.code(400);
|
|
return formatCoderoadChallengeCompletedValidation(error.validation);
|
|
} else {
|
|
fastify.errorHandler(error, request, reply);
|
|
}
|
|
}
|
|
},
|
|
async (req, reply) => {
|
|
const { 'coderoad-user-token': encodedUserToken } = req.headers;
|
|
const { tutorialId } = req.body;
|
|
|
|
let userToken;
|
|
try {
|
|
const payload = jwt.verify(encodedUserToken, JWT_SECRET) as JwtPayload;
|
|
userToken = payload.userToken;
|
|
} catch {
|
|
void reply.code(400);
|
|
return { type: 'error', msg: `invalid user token` } as const;
|
|
}
|
|
|
|
const tutorialRepo = tutorialId.split(':')[0];
|
|
const tutorialOrg = tutorialRepo?.split('/')?.[0];
|
|
|
|
if (tutorialOrg !== 'freeCodeCamp') {
|
|
void reply.code(400);
|
|
return {
|
|
type: 'error',
|
|
msg: `Tutorial not hosted on freeCodeCamp GitHub account`
|
|
} as const;
|
|
}
|
|
|
|
const codeRoadChallenges = challenges.filter(
|
|
({ challengeType }) =>
|
|
challengeType === challengeTypes.codeAllyPractice ||
|
|
challengeType === challengeTypes.codeAllyCert
|
|
);
|
|
|
|
const challenge = codeRoadChallenges.find(challenge => {
|
|
return tutorialRepo && challenge.url?.endsWith(tutorialRepo);
|
|
});
|
|
|
|
if (!challenge) {
|
|
void reply.code(400);
|
|
return { type: 'error', msg: 'Tutorial name is not valid' } as const;
|
|
}
|
|
|
|
const { id: challengeId, challengeType } = challenge;
|
|
try {
|
|
const tokenInfo = await fastify.prisma.userToken.findUnique({
|
|
where: { id: userToken }
|
|
});
|
|
|
|
if (!tokenInfo) {
|
|
void reply.code(400);
|
|
return { type: 'error', msg: 'User token not found' } as const;
|
|
}
|
|
|
|
const { userId } = tokenInfo;
|
|
|
|
const user = await fastify.prisma.user.findFirstOrThrow({
|
|
where: { id: userId }
|
|
});
|
|
|
|
if (!user) {
|
|
void reply.code(400);
|
|
return {
|
|
type: 'error',
|
|
msg: 'User for user token not found'
|
|
} as const;
|
|
}
|
|
|
|
const completedDate = Date.now();
|
|
const { completedChallenges = [], partiallyCompletedChallenges = [] } =
|
|
user;
|
|
|
|
const isCompleted = completedChallenges.some(
|
|
challenge => challenge.id === challengeId
|
|
);
|
|
|
|
if (challengeType === challengeTypes.codeAllyCert && !isCompleted) {
|
|
const finalChallenge = {
|
|
id: challengeId,
|
|
completedDate
|
|
};
|
|
|
|
await fastify.prisma.user.update({
|
|
where: { id: req.user?.id },
|
|
data: {
|
|
partiallyCompletedChallenges: uniqBy(
|
|
[finalChallenge, ...partiallyCompletedChallenges],
|
|
'id'
|
|
)
|
|
}
|
|
});
|
|
} else {
|
|
await updateUserChallengeData(fastify, user, challengeId, {
|
|
id: challengeId,
|
|
completedDate
|
|
});
|
|
}
|
|
} catch {
|
|
void reply.code(400);
|
|
return {
|
|
type: 'error',
|
|
msg: 'An error occurred trying to submit the challenge'
|
|
} as const;
|
|
}
|
|
return {
|
|
type: 'success',
|
|
msg: 'Successfully submitted challenge'
|
|
} as const;
|
|
}
|
|
);
|
|
|
|
fastify.post(
|
|
'/project-completed',
|
|
{
|
|
schema: schemas.projectCompleted,
|
|
errorHandler(error, request, reply) {
|
|
if (error.validation) {
|
|
void reply.code(400);
|
|
return formatProjectCompletedValidation(error.validation);
|
|
} else {
|
|
fastify.errorHandler(error, request, reply);
|
|
}
|
|
}
|
|
},
|
|
async (req, reply) => {
|
|
// TODO: considering validation is determined by `challengeType`, it should not come from the client
|
|
// Determine `challengeType` by `id`
|
|
const { id: projectId, challengeType, solution, githubLink } = req.body;
|
|
const userId = req.user?.id;
|
|
|
|
// If `backEndProject`:
|
|
// - `solution` needs to exist, but does not have to be valid URL
|
|
// - `githubLink` needs to exist and be valid URL
|
|
if (challengeType === challengeTypes.backEndProject) {
|
|
if (!solution || !isURL(githubLink + '')) {
|
|
return void reply.code(403).send({
|
|
type: 'error',
|
|
message: 'That does not appear to be a valid challenge submission.'
|
|
});
|
|
}
|
|
} else if (solution && !isURL(solution + '')) {
|
|
return void reply.code(403).send({
|
|
type: 'error',
|
|
message: 'That does not appear to be a valid challenge submission.'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const user = await fastify.prisma.user.findUniqueOrThrow({
|
|
where: { id: userId },
|
|
select: {
|
|
completedChallenges: true,
|
|
partiallyCompletedChallenges: true,
|
|
progressTimestamps: true
|
|
}
|
|
});
|
|
|
|
if (
|
|
challengeType === challengeTypes.codeAllyCert &&
|
|
!canSubmitCodeRoadCertProject(projectId, user)
|
|
) {
|
|
void reply.code(403);
|
|
return {
|
|
type: 'error',
|
|
message:
|
|
'You have to complete the project before you can submit a URL.'
|
|
} as const;
|
|
}
|
|
|
|
const completedDate = Date.now();
|
|
const oldChallenge = user.completedChallenges?.find(
|
|
({ id }) => id === projectId
|
|
);
|
|
|
|
const updatedChallenge = {
|
|
challengeType,
|
|
solution,
|
|
githubLink
|
|
};
|
|
const newChallenge = {
|
|
...updatedChallenge,
|
|
id: projectId,
|
|
completedDate
|
|
};
|
|
const alreadyCompleted = !!oldChallenge;
|
|
const progressTimestamps =
|
|
user.progressTimestamps as ProgressTimestamp[];
|
|
const points = getPoints(progressTimestamps);
|
|
|
|
const data = alreadyCompleted
|
|
? updateProject(projectId, updatedChallenge)
|
|
: createProject(projectId, newChallenge, progressTimestamps);
|
|
|
|
await fastify.prisma.user.update({
|
|
where: { id: userId },
|
|
data
|
|
});
|
|
|
|
return {
|
|
alreadyCompleted,
|
|
// TODO(Post-MVP): audit the client and remove this if the client does
|
|
// not use it.
|
|
completedDate,
|
|
points: alreadyCompleted ? points : points + 1
|
|
};
|
|
} catch (err) {
|
|
// TODO: send to Sentry
|
|
fastify.log.error(err);
|
|
void reply.code(500);
|
|
return {
|
|
message:
|
|
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.',
|
|
type: 'danger'
|
|
} as const;
|
|
}
|
|
}
|
|
);
|
|
|
|
fastify.post(
|
|
'/backend-challenge-completed',
|
|
{
|
|
schema: schemas.backendChallengeCompleted,
|
|
errorHandler(error, request, reply) {
|
|
if (error.validation) {
|
|
void reply.code(400);
|
|
return formatProjectCompletedValidation(error.validation);
|
|
} else {
|
|
fastify.errorHandler(error, request, reply);
|
|
}
|
|
}
|
|
},
|
|
async (req, reply) => {
|
|
try {
|
|
const user = await fastify.prisma.user.findUniqueOrThrow({
|
|
where: { id: req.user?.id }
|
|
});
|
|
const progressTimestamps = user.progressTimestamps as
|
|
| ProgressTimestamp[]
|
|
| null;
|
|
const points = getPoints(progressTimestamps);
|
|
|
|
const completedChallenge = {
|
|
completedDate: Date.now(),
|
|
...req.body
|
|
};
|
|
|
|
const { alreadyCompleted } = await updateUserChallengeData(
|
|
fastify,
|
|
user,
|
|
req.body.id,
|
|
completedChallenge
|
|
);
|
|
|
|
return {
|
|
alreadyCompleted,
|
|
points: alreadyCompleted ? points : points + 1,
|
|
completedDate: completedChallenge.completedDate
|
|
};
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
void reply.code(500);
|
|
return {
|
|
message:
|
|
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.',
|
|
type: 'danger'
|
|
} as const;
|
|
}
|
|
}
|
|
);
|
|
|
|
fastify.post(
|
|
'/modern-challenge-completed',
|
|
{
|
|
schema: schemas.modernChallengeCompleted,
|
|
errorHandler(error, request, reply) {
|
|
if (error.validation) {
|
|
void reply.code(400);
|
|
return formatProjectCompletedValidation(error.validation);
|
|
} else {
|
|
fastify.errorHandler(error, request, reply);
|
|
}
|
|
}
|
|
},
|
|
async (req, reply) => {
|
|
try {
|
|
const { id, files, challengeType } = req.body;
|
|
|
|
const user = await fastify.prisma.user.findUniqueOrThrow({
|
|
where: { id: req.user?.id }
|
|
});
|
|
const RawProgressTimestamp = user.progressTimestamps as
|
|
| ProgressTimestamp[]
|
|
| null;
|
|
const points = getPoints(RawProgressTimestamp);
|
|
|
|
const completedChallenge: CompletedChallenge = {
|
|
id,
|
|
files,
|
|
completedDate: Date.now()
|
|
};
|
|
|
|
if (challengeType === challengeTypes.multifileCertProject) {
|
|
completedChallenge.isManuallyApproved = false;
|
|
user.needsModeration = true;
|
|
}
|
|
|
|
if (
|
|
jsCertProjectIds.includes(id) ||
|
|
multifileCertProjectIds.includes(id) ||
|
|
multifilePythonCertProjectIds.includes(id)
|
|
) {
|
|
completedChallenge.challengeType = challengeType;
|
|
}
|
|
|
|
const { alreadyCompleted, userSavedChallenges: savedChallenges } =
|
|
await updateUserChallengeData(fastify, user, id, completedChallenge);
|
|
|
|
return {
|
|
alreadyCompleted,
|
|
points: alreadyCompleted ? points : points + 1,
|
|
completedDate: completedChallenge.completedDate,
|
|
savedChallenges
|
|
};
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
void reply.code(500);
|
|
return {
|
|
message:
|
|
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.',
|
|
type: 'danger'
|
|
} as const;
|
|
}
|
|
}
|
|
);
|
|
|
|
fastify.post(
|
|
'/save-challenge',
|
|
{
|
|
schema: schemas.saveChallenge,
|
|
errorHandler(error, request, reply) {
|
|
if (error.validation) {
|
|
void reply.code(400);
|
|
return formatProjectCompletedValidation(error.validation);
|
|
} else {
|
|
fastify.errorHandler(error, request, reply);
|
|
}
|
|
}
|
|
},
|
|
async (req, reply) => {
|
|
try {
|
|
const { files, id: challengeId } = req.body;
|
|
const user = await fastify.prisma.user.findUniqueOrThrow({
|
|
where: { id: req.user?.id }
|
|
});
|
|
const challenge = {
|
|
id: challengeId,
|
|
files
|
|
};
|
|
|
|
if (
|
|
!multifileCertProjectIds.includes(challengeId) &&
|
|
!multifilePythonCertProjectIds.includes(challengeId)
|
|
) {
|
|
void reply.code(403);
|
|
return 'That challenge type is not saveable.';
|
|
}
|
|
|
|
const userSavedChallenges = saveUserChallengeData(
|
|
challengeId,
|
|
user.savedChallenges,
|
|
challenge
|
|
);
|
|
|
|
await fastify.prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
savedChallenges: userSavedChallenges
|
|
}
|
|
});
|
|
|
|
return { savedChallenges: userSavedChallenges };
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
void reply.code(500);
|
|
return {
|
|
message:
|
|
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.',
|
|
type: 'danger'
|
|
} as const;
|
|
}
|
|
}
|
|
);
|
|
|
|
fastify.get(
|
|
'/exam/:id',
|
|
{
|
|
schema: schemas.exam,
|
|
errorHandler(error, request, reply) {
|
|
if (error.validation) {
|
|
void reply.code(400);
|
|
return { error: `Valid 'id' not found in request parameters.` };
|
|
} else {
|
|
fastify.errorHandler(error, request, reply);
|
|
}
|
|
}
|
|
},
|
|
async (req, reply) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const { completedChallenges } =
|
|
await fastify.prisma.user.findUniqueOrThrow({
|
|
where: { id: req.user?.id },
|
|
select: { completedChallenges: true }
|
|
});
|
|
|
|
const examFromDb = await fastify.prisma.exam.findUnique({
|
|
where: { id }
|
|
});
|
|
|
|
if (!examFromDb) {
|
|
void reply.code(500);
|
|
return {
|
|
error: 'An error occurred trying to get the exam from the database.'
|
|
};
|
|
}
|
|
|
|
const validExamFromDbSchema = validateExamFromDbSchema(examFromDb);
|
|
|
|
if ('error' in validExamFromDbSchema) {
|
|
void reply.code(500);
|
|
return {
|
|
error:
|
|
'An error occurred validating the exam information from the database.'
|
|
};
|
|
}
|
|
|
|
const { prerequisites, numberOfQuestionsInExam, title } = examFromDb;
|
|
|
|
// Validate User has completed prerequisite challenges
|
|
const prerequisiteIds = prerequisites.map(p => p.id);
|
|
const completedPrerequisites = completedChallenges.filter(c =>
|
|
prerequisiteIds.includes(c.id)
|
|
);
|
|
|
|
if (completedPrerequisites.length !== prerequisiteIds.length) {
|
|
void reply.code(403);
|
|
return {
|
|
error: `You have not completed the required challenges to start the '${title}'.`
|
|
};
|
|
}
|
|
|
|
const randomizedExam = generateRandomExam(examFromDb);
|
|
const validGeneratedExamSchema = validateGeneratedExamSchema(
|
|
randomizedExam,
|
|
numberOfQuestionsInExam
|
|
);
|
|
|
|
if ('error' in validGeneratedExamSchema) {
|
|
void reply.code(500);
|
|
return { error: 'An error occurred trying to randomize the exam.' };
|
|
}
|
|
|
|
return {
|
|
generatedExam: randomizedExam
|
|
};
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
void reply.code(500);
|
|
return {
|
|
error: 'Something went wrong trying to generate your exam.'
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
fastify.post(
|
|
'/ms-trophy-challenge-completed',
|
|
{
|
|
schema: schemas.msTrophyChallengeCompleted,
|
|
errorHandler(error, request, reply) {
|
|
if (error.validation) {
|
|
void reply.code(400);
|
|
void reply.send({ type: 'error', message: 'flash.ms.trophy.err-2' });
|
|
} else {
|
|
fastify.errorHandler(error, request, reply);
|
|
}
|
|
}
|
|
},
|
|
async (req, reply) => {
|
|
try {
|
|
const challengeId = req.body.id;
|
|
const challenge = msTrophyChallenges.find(
|
|
challenge => challenge.id === challengeId
|
|
);
|
|
|
|
if (!challenge) {
|
|
return reply
|
|
.code(400)
|
|
.send({ type: 'error', message: 'flash.ms.trophy.err-2' });
|
|
}
|
|
|
|
const msUser = await fastify.prisma.msUsername.findFirst({
|
|
where: { userId: req.user?.id }
|
|
});
|
|
|
|
if (!msUser || !msUser.msUsername) {
|
|
return reply
|
|
.code(403)
|
|
.send({ type: 'error', message: 'flash.ms.trophy.err-1' });
|
|
}
|
|
|
|
const { msUsername } = msUser;
|
|
|
|
// TODO: log error if msTrophyId not found?
|
|
const msTrophyId = challenge.msTrophyId ?? '';
|
|
|
|
const msTrophyStatus = await verifyTrophyWithMicrosoft({
|
|
msUsername,
|
|
msTrophyId
|
|
});
|
|
|
|
if (msTrophyStatus.type === 'error') {
|
|
return reply.code(403).send(msTrophyStatus);
|
|
}
|
|
|
|
const user = await fastify.prisma.user.findUniqueOrThrow({
|
|
where: { id: req.user?.id },
|
|
select: { completedChallenges: true, progressTimestamps: true }
|
|
});
|
|
|
|
const { completedChallenges } = user;
|
|
const progressTimestamps =
|
|
user.progressTimestamps as ProgressTimestamp[];
|
|
const oldChallenge = completedChallenges.find(
|
|
({ id }) => id === challengeId
|
|
);
|
|
const alreadyCompleted = !!oldChallenge;
|
|
const completedDate = alreadyCompleted
|
|
? oldChallenge.completedDate
|
|
: Date.now();
|
|
|
|
if (!alreadyCompleted) {
|
|
const newChallenge = {
|
|
id: challengeId,
|
|
completedDate,
|
|
solution: msTrophyStatus.msUserAchievementsApiUrl
|
|
};
|
|
await fastify.prisma.user.update({
|
|
where: { id: req.user?.id },
|
|
data: {
|
|
completedChallenges: {
|
|
push: newChallenge
|
|
},
|
|
progressTimestamps: [...progressTimestamps, completedDate]
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
alreadyCompleted,
|
|
points: getPoints(progressTimestamps) + (alreadyCompleted ? 0 : 1),
|
|
completedDate
|
|
};
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
void reply.code(500);
|
|
return {
|
|
type: 'error',
|
|
message: 'flash.ms.trophy.err-5'
|
|
} as const;
|
|
}
|
|
}
|
|
);
|
|
|
|
fastify.post(
|
|
'/exam-challenge-completed',
|
|
{
|
|
schema: schemas.examChallengeCompleted,
|
|
errorHandler(error, request, reply) {
|
|
if (error.validation) {
|
|
void reply.code(400);
|
|
void reply.send({
|
|
error: 'Valid request body not found in attempt to submit exam.'
|
|
});
|
|
} else {
|
|
fastify.errorHandler(error, request, reply);
|
|
}
|
|
}
|
|
},
|
|
async (req, reply) => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
const { userCompletedExam, id, challengeType } = req.body;
|
|
|
|
const { completedChallenges, completedExams, progressTimestamps } =
|
|
await fastify.prisma.user.findUniqueOrThrow({
|
|
where: { id: userId },
|
|
select: {
|
|
completedChallenges: true,
|
|
completedExams: true,
|
|
progressTimestamps: true
|
|
}
|
|
});
|
|
|
|
const examFromDb = await fastify.prisma.exam.findUnique({
|
|
where: { id }
|
|
});
|
|
|
|
if (!examFromDb) {
|
|
void reply.code(400);
|
|
return {
|
|
error: 'An error occurred trying to get the exam from the database.'
|
|
};
|
|
}
|
|
|
|
const validExamFromDbSchema = validateExamFromDbSchema(examFromDb);
|
|
if ('error' in validExamFromDbSchema) {
|
|
void reply.code(500);
|
|
return {
|
|
error:
|
|
'An error occurred validating the exam information from the database.'
|
|
};
|
|
}
|
|
|
|
const { prerequisites, numberOfQuestionsInExam, title } = examFromDb;
|
|
|
|
const prerequisiteIds = prerequisites.map(p => p.id);
|
|
const completedPrerequisites = completedChallenges.filter(c =>
|
|
prerequisiteIds.includes(c.id)
|
|
);
|
|
|
|
if (completedPrerequisites.length !== prerequisiteIds.length) {
|
|
void reply.code(403);
|
|
return {
|
|
error: `You have not completed the required challenges to start the '${title}'.`
|
|
};
|
|
}
|
|
|
|
const validUserCompletedExam = validateUserCompletedExamSchema(
|
|
userCompletedExam,
|
|
numberOfQuestionsInExam
|
|
);
|
|
if ('error' in validUserCompletedExam) {
|
|
fastify.log.error(validUserCompletedExam.error);
|
|
void reply.code(400);
|
|
return {
|
|
error: 'An error occurred validating the submitted exam.'
|
|
};
|
|
}
|
|
|
|
const examResults = createExamResults(userCompletedExam, examFromDb);
|
|
|
|
const validExamResults = validateExamResultsSchema(examResults);
|
|
if ('error' in validExamResults) {
|
|
fastify.log.error(validExamResults.error);
|
|
void reply.code(500);
|
|
return {
|
|
error: 'An error occurred validating the submitted exam.'
|
|
};
|
|
}
|
|
|
|
const newCompletedChallenges: CompletedChallenge[] =
|
|
completedChallenges;
|
|
const newCompletedExams: CompletedExam[] = completedExams;
|
|
const newProgressTimeStamps = progressTimestamps as ProgressTimestamp[];
|
|
const completedDate = Date.now();
|
|
|
|
const newCompletedChallenge = {
|
|
id,
|
|
challengeType,
|
|
completedDate,
|
|
examResults
|
|
};
|
|
|
|
// Always push to completedExams[] to keep a record of all exams taken.
|
|
newCompletedExams.push(newCompletedChallenge);
|
|
|
|
let addPoint = false;
|
|
|
|
const alreadyCompletedIndex = completedChallenges.findIndex(
|
|
c => c.id === id
|
|
);
|
|
|
|
const alreadyCompleted = alreadyCompletedIndex >= 0;
|
|
|
|
if (examResults.passed) {
|
|
if (alreadyCompleted) {
|
|
const { percentCorrect } = examResults;
|
|
const oldChallenge = completedChallenges[
|
|
alreadyCompletedIndex
|
|
] as CompletedChallenge;
|
|
const oldResults = oldChallenge?.examResults as ExamResults;
|
|
|
|
const newChallenge = oldChallenge;
|
|
newChallenge ? (newChallenge.examResults = examResults) : null;
|
|
|
|
// only update if it's a better result
|
|
if (percentCorrect > oldResults.percentCorrect) {
|
|
const updatedChallege = {
|
|
id,
|
|
challengeType: oldChallenge.challengeType,
|
|
completedDate: oldChallenge.completedDate,
|
|
examResults
|
|
};
|
|
|
|
newCompletedChallenges[alreadyCompletedIndex] = updatedChallege;
|
|
|
|
await fastify.prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
completedExams: newCompletedExams,
|
|
completedChallenges: newCompletedChallenges
|
|
}
|
|
});
|
|
} else {
|
|
await fastify.prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
completedExams: newCompletedExams
|
|
}
|
|
});
|
|
}
|
|
|
|
// not already completed, push to completedChallenges
|
|
} else {
|
|
addPoint = true;
|
|
newCompletedChallenges.push(newCompletedChallenge);
|
|
|
|
await fastify.prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
completedExams: newCompletedExams,
|
|
completedChallenges: newCompletedChallenges,
|
|
progressTimestamps: [
|
|
...newProgressTimeStamps,
|
|
newCompletedChallenge.completedDate
|
|
]
|
|
}
|
|
});
|
|
}
|
|
|
|
// exam not passed
|
|
} else {
|
|
await fastify.prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
completedExams: newCompletedExams
|
|
}
|
|
});
|
|
}
|
|
|
|
const points = getPoints(newProgressTimeStamps);
|
|
|
|
return {
|
|
alreadyCompleted,
|
|
points: addPoint ? points + 1 : points,
|
|
completedDate,
|
|
examResults
|
|
};
|
|
} catch (error) {
|
|
fastify.log.error(error);
|
|
void reply.code(500);
|
|
return {
|
|
error: 'An error occurred trying to submit your exam.'
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
done();
|
|
};
|