feat(api): create save challenge route (#50040)

Co-authored-by: Niraj Nandish <nirajnandish@icloud.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Muhammed Mustafa
2023-09-30 20:56:57 +03:00
committed by GitHub
parent a0fcc3cbb7
commit 2d2684ac8b
4 changed files with 206 additions and 36 deletions
+70
View File
@@ -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', () => {
+57 -1
View File
@@ -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();
};
+45 -30
View File
@@ -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.'
)
})
}
}
};
+34 -5
View File
@@ -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<SavedChallenge, 'lastSavedDate'>
) {
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,