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:
@ -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$() {
|
||||
|
@ -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": []
|
||||
|
@ -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']);
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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 }
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -38,6 +38,7 @@ export const publicUserProps = [
|
||||
'portfolio',
|
||||
'profileUI',
|
||||
'projects',
|
||||
'savedChallenges',
|
||||
'streak',
|
||||
'twitter',
|
||||
'username',
|
||||
|
Reference in New Issue
Block a user