diff --git a/api/src/routes/challenge.test.ts b/api/src/routes/challenge.test.ts index f15fd407f36..50e124456ac 100644 --- a/api/src/routes/challenge.test.ts +++ b/api/src/routes/challenge.test.ts @@ -917,6 +917,76 @@ describe('challengeRoutes', () => { }); }); }); + + describe('/save-challenge', () => { + describe('validation', () => { + test('POST returns 403 status for unsavable challenges', async () => { + const response = await superRequest('/save-challenge', { + method: 'POST', + setCookies + }).send({ + savedChallenges: { + // valid mongo id, but not a savable one + id: 'aaaaaaaaaaaaaaaaaaaaaaa', + files: multiFileCertProjectBody.files + } + }); + + expect(response.body).toEqual({ + message: 'That does not appear to be a valid challenge submission.', + type: 'error' + }); + expect(response.statusCode).toBe(400); + }); + }); + + describe('handling', () => { + afterEach(async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { email: 'foo@bar.com' }, + data: { + savedChallenges: [] + } + }); + }); + + test('POST update the user savedchallenges and return them', async () => { + const response = await superRequest('/save-challenge', { + method: 'POST', + setCookies + }).send({ + id: multiFileCertProjectId, + files: updatedMultiFileCertProjectBody.files + }); + + const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({ + where: { email: 'foo@bar.com' } + }); + + const savedDate = user.savedChallenges[0]?.lastSavedDate; + + expect(user).toMatchObject({ + savedChallenges: [ + { + id: multiFileCertProjectId, + lastSavedDate: savedDate, + files: updatedMultiFileCertProjectBody.files + } + ] + }); + expect(response.body).toEqual({ + savedChallenges: [ + { + id: multiFileCertProjectId, + lastSavedDate: savedDate, + files: updatedMultiFileCertProjectBody.files + } + ] + }); + expect(response.statusCode).toBe(200); + }); + }); + }); }); describe('Unauthenticated user', () => { diff --git a/api/src/routes/challenge.ts b/api/src/routes/challenge.ts index c0a0bee8c1e..6a59c9f76d7 100644 --- a/api/src/routes/challenge.ts +++ b/api/src/routes/challenge.ts @@ -7,7 +7,8 @@ import { jsCertProjectIds, multifileCertProjectIds, updateUserChallengeData, - type CompletedChallenge + type CompletedChallenge, + saveUserChallengeData } from '../utils/common-challenge-functions'; import { JWT_SECRET } from '../utils/env'; import { @@ -367,5 +368,60 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = ( } ); + 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.session.user.id } + }); + const challenge = { + id: challengeId, + files + }; + + if (!multifileCertProjectIds.includes(challengeId)) { + void reply.code(403); + return 'That challenge type is not savable.'; + } + + 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; + } + } + ); + done(); }; diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 52229aeb9c9..fe2b5f8a73d 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -7,6 +7,23 @@ const generic500 = Type.Object({ type: Type.Literal('danger') }); +const file = Type.Object({ + contents: Type.String(), + key: Type.String(), + ext: Type.String(), + name: Type.String(), + history: Type.Array(Type.String()) +}); + +const saveChallengeBody = Type.Object({ + id: Type.String({ + format: 'objectid', + maxLength: 24, + minLength: 24 + }), + files: Type.Array(file) +}); + export const schemas = { // Settings: updateMyProfileUI: { @@ -312,19 +329,10 @@ export const schemas = { joinDate: Type.String(), savedChallenges: Type.Optional( Type.Array( - Type.Object({ - id: Type.String(), - lastSavedDate: Type.Number(), - files: Type.Array( - Type.Object({ - contents: Type.String(), - key: Type.String(), - ext: Type.String(), - name: Type.String(), - history: Type.Array(Type.String()) - }) - ) - }) + Type.Intersect([ + saveChallengeBody, + Type.Object({ lastSavedDate: Type.Number() }) + ]) ) ), username: Type.String(), @@ -477,23 +485,10 @@ export const schemas = { points: Type.Number(), alreadyCompleted: Type.Boolean(), savedChallenges: Type.Array( - Type.Object({ - id: Type.String({ - format: 'objectid', - maxLength: 24, - minLength: 24 - }), - lastSavedDate: Type.Number(), - files: Type.Array( - Type.Object({ - contents: Type.String(), - key: Type.String(), - ext: Type.String(), - name: Type.String(), - history: Type.Array(Type.String()) - }) - ) - }) + Type.Intersect([ + saveChallengeBody, + Type.Object({ lastSavedDate: Type.Number() }) + ]) ) }), 400: Type.Object({ @@ -509,5 +504,25 @@ export const schemas = { ) }) } + }, + saveChallenge: { + body: saveChallengeBody, + response: { + 200: Type.Object({ + savedChallenges: Type.Array( + Type.Intersect([ + saveChallengeBody, + Type.Object({ lastSavedDate: Type.Number() }) + ]) + ) + }), + 403: Type.Literal('That challenge type is not savable.'), + 500: Type.Object({ + type: Type.Literal('danger'), + message: Type.Literal( + 'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.' + ) + }) + } } }; diff --git a/api/src/utils/common-challenge-functions.ts b/api/src/utils/common-challenge-functions.ts index a219452c941..679c68e5a63 100644 --- a/api/src/utils/common-challenge-functions.ts +++ b/api/src/utils/common-challenge-functions.ts @@ -16,10 +16,6 @@ export const multifileCertProjectIds = getChallenges() .filter(c => c.challengeType === challengeTypes.multifileCertProject) .map(c => c.id); -const savableChallenges = getChallenges() - .filter(c => c.challengeType === challengeTypes.multifileCertProject) - .map(c => c.id); - type SavedChallengeFile = { key: string; ext: string; // NOTE: This is Ext type in client @@ -65,6 +61,39 @@ export type CompletedChallenge = { files?: CompletedChallengeFile[]; }; +/** + * Helper function to save a user's challenge data. Used in challenge + * submission endpoints. + * + * @param challengeId The id of the submitted challenge. + * @param savedChallenges The user's saved challenges array. + * @param challenge The saveble challenge. + * @returns Update or push the saved challenges. + */ +export function saveUserChallengeData( + challengeId: string, + savedChallenges: SavedChallenge[], + challenge: Omit +) { + const challengeToSave: SavedChallenge = { + id: challengeId, + lastSavedDate: Date.now(), + files: challenge.files?.map(file => + pick(file, ['contents', 'key', 'name', 'ext', 'history']) + ) + }; + + const savedIndex = savedChallenges.findIndex(({ id }) => challengeId === id); + + if (savedIndex >= 0) { + savedChallenges[savedIndex] = challengeToSave; + } else { + savedChallenges.push(challengeToSave); + } + + return savedChallenges; +} + /** * Helper function to update a user's challenge data. Used in challenge * submission endpoints. @@ -152,7 +181,7 @@ export async function updateUserChallengeData( userCompletedChallenges.push(finalChallenge); } - if (savableChallenges.includes(challengeId)) { + if (multifileCertProjectIds.includes(challengeId)) { const challengeToSave: SavedChallenge = { id: challengeId, lastSavedDate: newProgressTimeStamp,