diff --git a/api-server/src/common/models/user.js b/api-server/src/common/models/user.js index c8fe2571f0..31efb6a04d 100644 --- a/api-server/src/common/models/user.js +++ b/api-server/src/common/models/user.js @@ -994,6 +994,20 @@ export default function initializeUser(User) { return user.completedChallenges; }); }; + User.prototype.getSavedChallenges$ = function getSavedChallenges$() { + if (Array.isArray(this.savedChallenges) && this.savedChallenges.length) { + return Observable.of(this.savedChallenges); + } + const id = this.getId(); + const filter = { + where: { id }, + fields: { savedChallenges: true } + }; + return this.constructor.findOne$(filter).map(user => { + this.savedChallenges = user.savedChallenges; + return user.savedChallenges; + }); + }; User.prototype.getPartiallyCompletedChallenges$ = function getPartiallyCompletedChallenges$() { diff --git a/api-server/src/common/models/user.json b/api-server/src/common/models/user.json index e65bd7fa92..cfa8b432a7 100644 --- a/api-server/src/common/models/user.json +++ b/api-server/src/common/models/user.json @@ -250,6 +250,39 @@ ], "default": [] }, + "savedChallenges": { + "type": [ + { + "lastSavedDate": "number", + "id": "string", + "challengeType": "number", + "files": { + "type": [ + { + "contents": { + "type": "string", + "default": "" + }, + "ext": { + "type": "string" + }, + "path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "key": { + "type": "string" + } + } + ], + "default": [] + } + } + ], + "default": [] + }, "portfolio": { "type": "array", "default": [] diff --git a/api-server/src/common/utils/index.js b/api-server/src/common/utils/index.js index b756789c7e..556c2f839a 100644 --- a/api-server/src/common/utils/index.js +++ b/api-server/src/common/utils/index.js @@ -21,5 +21,8 @@ export const fixCompletedChallengeItem = obj => 'isManuallyApproved' ]); +export const fixSavedChallengeItem = obj => + pick(obj, ['id', 'lastSavedDate', 'files']); + export const fixPartiallyCompletedChallengeItem = obj => pick(obj, ['id', 'completedDate']); diff --git a/api-server/src/server/boot/challenge.js b/api-server/src/server/boot/challenge.js index e5c8c33d68..4b734adbeb 100644 --- a/api-server/src/server/boot/challenge.js +++ b/api-server/src/server/boot/challenge.js @@ -18,7 +18,8 @@ import { jwtSecret } from '../../../../config/secrets'; import { environment, deploymentEnv } from '../../../../config/env.json'; import { fixCompletedChallengeItem, - fixPartiallyCompletedChallengeItem + fixPartiallyCompletedChallengeItem, + fixSavedChallengeItem } from '../../common/utils'; import { getChallenges } from '../utils/get-curriculum'; import { ifNoUserSend } from '../utils/middleware'; @@ -64,6 +65,13 @@ export default async function bootChallenge(app, done) { backendChallengeCompleted ); + api.post( + '/save-challenge', + send200toNonUser, + isValidChallengeCompletion, + saveChallenge + ); + router.get('/challenges/current-challenge', redirectToCurrentChallenge); const coderoadChallengeCompleted = createCoderoadChallengeCompleted(app); @@ -87,6 +95,33 @@ const multiFileCertProjectIds = getChallenges() .filter(challenge => challenge.challengeType === 14) .map(challenge => challenge.id); +const savableChallenges = getChallenges() + .filter(challenge => challenge.challengeType === 14) + .map(challenge => challenge.id); + +function buildNewSavedChallenges({ + user, + challengeId, + completedDate = Date.now(), + files +}) { + const { savedChallenges } = user; + const challengeToSave = { + id: challengeId, + lastSavedDate: completedDate, + files: files?.map(file => + pick(file, ['contents', 'key', 'name', 'ext', 'history']) + ) + }; + + const newSavedChallenges = uniqBy( + [challengeToSave, ...savedChallenges.map(fixSavedChallengeItem)], + 'id' + ); + + return newSavedChallenges; +} + export function buildUserUpdate( user, challengeId, @@ -101,7 +136,7 @@ export function buildUserUpdate( ) { completedChallenge = { ..._completedChallenge, - files: files.map(file => + files: files?.map(file => pick(file, ['contents', 'key', 'index', 'name', 'path', 'ext']) ) }; @@ -133,13 +168,34 @@ export function buildUserUpdate( }; } - updateData.$set = { - completedChallenges: uniqBy( - [finalChallenge, ...completedChallenges.map(fixCompletedChallengeItem)], - 'id' - ) - }; + let newSavedChallenges; + if (savableChallenges.includes(challengeId)) { + newSavedChallenges = buildNewSavedChallenges({ + user, + challengeId, + completedDate, + files + }); + + // if savableChallenge, update saved array when submitting + updateData.$set = { + completedChallenges: uniqBy( + [finalChallenge, ...completedChallenges.map(fixCompletedChallengeItem)], + 'id' + ), + savedChallenges: newSavedChallenges + }; + } else { + updateData.$set = { + completedChallenges: uniqBy( + [finalChallenge, ...completedChallenges.map(fixCompletedChallengeItem)], + 'id' + ) + }; + } + + // remove from partiallyCompleted on submit updateData.$pull = { partiallyCompletedChallenges: { id: challengeId } }; @@ -157,7 +213,8 @@ export function buildUserUpdate( return { alreadyCompleted, updateData, - completedDate: finalChallenge.completedDate + completedDate: finalChallenge.completedDate, + savedChallenges: newSavedChallenges }; } @@ -214,6 +271,7 @@ export function isValidChallengeCompletion(req, res, next) { body: { id, challengeType, solution } } = req; + // ToDO: Validate other things (challengeFiles, etc) const isValidChallengeCompletionErrorMsg = { type: 'error', message: 'That does not appear to be a valid challenge submission.' @@ -248,6 +306,7 @@ export function modernChallengeCompleted(req, res, next) { completedDate }; + // if multifile cert project if (challengeType === 14) { completedChallenge.isManuallyApproved = false; } @@ -261,11 +320,12 @@ export function modernChallengeCompleted(req, res, next) { completedChallenge.challengeType = challengeType; } - const { alreadyCompleted, updateData } = buildUserUpdate( + const { alreadyCompleted, savedChallenges, updateData } = buildUserUpdate( user, id, completedChallenge ); + const points = alreadyCompleted ? user.points : user.points + 1; const updatePromise = new Promise((resolve, reject) => user.updateAttributes(updateData, err => { @@ -279,7 +339,8 @@ export function modernChallengeCompleted(req, res, next) { return res.json({ points, alreadyCompleted, - completedDate + completedDate, + savedChallenges }); }); }) @@ -389,6 +450,45 @@ function backendChallengeCompleted(req, res, next) { .subscribe(() => {}, next); } +function saveChallenge(req, res, next) { + const user = req.user; + const { id: challengeId, files = [] } = req.body; + + if (!savableChallenges.includes(challengeId)) { + return res.status(403).send('That challenge type is not savable'); + } + + const newSavedChallenges = buildNewSavedChallenges({ + user, + challengeId, + completedDate: Date.now(), + files + }); + + return user + .getSavedChallenges$() + .flatMap(() => { + const updateData = {}; + updateData.$set = { + savedChallenges: newSavedChallenges + }; + const updatePromise = new Promise((resolve, reject) => + user.updateAttributes(updateData, err => { + if (err) { + return reject(err); + } + return resolve(); + }) + ); + return Observable.fromPromise(updatePromise).doOnNext(() => { + return res.json({ + savedChallenges: newSavedChallenges + }); + }); + }) + .subscribe(() => {}, next); +} + const codeRoadChallenges = getChallenges().filter( ({ challengeType }) => challengeType === 12 || challengeType === 13 ); diff --git a/api-server/src/server/boot/user.js b/api-server/src/server/boot/user.js index 0bf4a4115f..37480003a1 100644 --- a/api-server/src/server/boot/user.js +++ b/api-server/src/server/boot/user.js @@ -6,7 +6,8 @@ import { Observable } from 'rx'; import { fixCompletedChallengeItem, - fixPartiallyCompletedChallengeItem + fixPartiallyCompletedChallengeItem, + fixSavedChallengeItem } from '../../common/utils'; import { removeCookies } from '../utils/getSetAccessToken'; import { ifNoUser401, ifNoUserRedirectHome } from '../utils/middleware'; @@ -114,18 +115,21 @@ function createReadSessionUser(app) { Observable.forkJoin( queryUser.getCompletedChallenges$(), queryUser.getPartiallyCompletedChallenges$(), + queryUser.getSavedChallenges$(), queryUser.getPoints$(), Donation.getCurrentActiveDonationCount$(), ( completedChallenges, partiallyCompletedChallenges, + savedChallenges, progressTimestamps, activeDonations ) => ({ activeDonations, completedChallenges, partiallyCompletedChallenges, - progress: getProgress(progressTimestamps, queryUser.timezone) + progress: getProgress(progressTimestamps, queryUser.timezone), + savedChallenges }) ); Observable.if( @@ -137,7 +141,8 @@ function createReadSessionUser(app) { activeDonations, completedChallenges, partiallyCompletedChallenges, - progress + progress, + savedChallenges }) => ({ user: { ...queryUser.toJSON(), @@ -147,7 +152,8 @@ function createReadSessionUser(app) { ), partiallyCompletedChallenges: partiallyCompletedChallenges.map( fixPartiallyCompletedChallengeItem - ) + ), + savedChallenges: savedChallenges.map(fixSavedChallengeItem) }, sessionMeta: { activeDonations } }) diff --git a/api-server/src/server/middlewares/error-handlers.js b/api-server/src/server/middlewares/error-handlers.js index 573e817077..e78f26a3e2 100644 --- a/api-server/src/server/middlewares/error-handlers.js +++ b/api-server/src/server/middlewares/error-handlers.js @@ -26,6 +26,11 @@ export default function prodErrorHandler() { // error handling in production. // eslint-disable-next-line no-unused-vars return function (err, req, res, next) { + // response for when req.body is bigger than body-parser's size limit + if (err?.type === 'entity.too.large') { + return res.status('413').send('Request payload is too large'); + } + const { origin } = getRedirectParams(req); const handled = unwrapHandledError(err); // respect handled error status diff --git a/api-server/src/server/utils/publicUserProps.js b/api-server/src/server/utils/publicUserProps.js index ca71fd21de..e6b8871e69 100644 --- a/api-server/src/server/utils/publicUserProps.js +++ b/api-server/src/server/utils/publicUserProps.js @@ -38,6 +38,7 @@ export const publicUserProps = [ 'portfolio', 'profileUI', 'projects', + 'savedChallenges', 'streak', 'twitter', 'username', diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index b0fc4200ae..388ad6aaf0 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -58,6 +58,7 @@ "resubscribe": "You can click here to resubscribe", "click-here": "Click here to sign in", "save": "Save", + "save-code": "Save your Code", "no-thanks": "No thanks", "yes-please": "Yes please", "update-email": "Update my Email", @@ -529,7 +530,11 @@ "start-project-err": "Something went wrong trying to start the project. Please try again.", "complete-project-first": "You must complete the project first.", "local-code-save-error": "Oops, your code did not save, your browser's local storage may be full.", - "local-code-saved": "Saved! Your code was saved to your browser's local storage." + "local-code-saved": "Saved! Your code was saved to your browser's local storage.", + "code-saved": "Your code was saved to the database. It will be here when you return.", + "code-save-error": "An error occurred trying to save your code.", + "challenge-save-too-big": "Sorry, you cannot save your code. Your code is {{user-size}} bytes. We allow a maximum of {{max-size}} bytes. Please make your code smaller and try again or request assistance on https://forum.freecodecamp.org", + "challenge-submit-too-big": "Sorry, you cannot submit your code. Your code is {{user-size}} bytes. We allow a maximum of {{max-size}} bytes. Please make your code smaller and try again or request assistance on https://forum.freecodecamp.org" }, "validation": { "max-characters": "There is a maximum limit of 288 characters, you have {{charsLeft}} left", diff --git a/client/src/components/Flash/redux/flash-messages.ts b/client/src/components/Flash/redux/flash-messages.ts index efd4d37109..0658f13b93 100644 --- a/client/src/components/Flash/redux/flash-messages.ts +++ b/client/src/components/Flash/redux/flash-messages.ts @@ -5,6 +5,10 @@ export enum FlashMessages { CertClaimSuccess = 'flash.cert-claim-success', CertificateMissing = 'flash.certificate-missing', CertsPrivate = 'flash.certs-private', + ChallengeSaveTooBig = 'flash.challenge-save-too-big', + ChallengeSubmitTooBig = 'flash.challenge-submit-too-big', + CodeSaved = 'flash.code-saved', + CodeSaveError = 'flash.code-save-error', CompleteProjectFirst = 'flash.complete-project-first', DeleteTokenErr = 'flash.delete-token-err', EmailValid = 'flash.email-valid', diff --git a/client/src/components/layouts/default.tsx b/client/src/components/layouts/default.tsx index 8d006b8e00..6802b377a0 100644 --- a/client/src/components/layouts/default.tsx +++ b/client/src/components/layouts/default.tsx @@ -209,7 +209,7 @@ class DefaultLayout extends Component { removeFlashMessage={removeFlashMessage} /> ) : null} - {children} + {fetchState.complete && children} {showFooter &&