mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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.'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user