feat: let users save cert project code to db (#44700)

* feat: let users save cert project code to db

fix: move getChallenges call out of request function so it only runs once

fix: use FlashMessages enum

fix: transform challengeFiles earlier

test: make tribute page use multifile editor

stuff I was playing with - revert this to get it to a working state

refactor: allow undefined editableRegionBoundaries

fix: save history

history is not necessarily ["name.ext"] and using the incorrect history
could cause weird bugs

fix: replace files -> challengeFiles on the client

refactor: DRY out ajax

fix: use file -> challengefile map

refactor: rename ajax types

fix: alphatize flash-messages.ts

revert: tribute page project

fix: remove logs

fix: prettier

fix: cypress

fix: prettier

fix: remove submitComplete action

fix: block UI for new projects

fix: handle code size

* fix: catch undefined files

* fix: don't default to undefined when it's already the default

* fix: only update savedChallenges if applicable

* fix: dehumidify backend + fine tune nearby stuff

* fix: prop-types

* fix: dehumidify sagas

* fix: variable name

* fix: types

* Apply suggestions from code review

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>

* fix: typo

* fix: prettier

* fix: props types

* fix: flash messages

* Update client/src/utils/challenge-request-helpers.ts

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* chore: rename function uniformize -> standardize

* fix: flash message

* fix: add link to forum on flash messages

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Tom
2022-03-31 10:34:40 -05:00
committed by GitHub
parent ed66e5d01c
commit 9f753a5662
29 changed files with 547 additions and 94 deletions

View File

@ -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$() {

View File

@ -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": []

View File

@ -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']);

View File

@ -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
);

View File

@ -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 }
})

View File

@ -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

View File

@ -38,6 +38,7 @@ export const publicUserProps = [
'portfolio',
'profileUI',
'projects',
'savedChallenges',
'streak',
'twitter',
'username',