From 07d54a455c0db519c6e67a3ada61d4c2e494273c Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Tue, 9 Feb 2016 13:22:43 -0800 Subject: [PATCH 01/14] Add challengeMap migrations --- common/models/user.json | 10 ++++ server/middleware.json | 3 +- .../migrate-completed-challenges.js | 60 +++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 server/middlewares/migrate-completed-challenges.js diff --git a/common/models/user.json b/common/models/user.json index ad68b5c2fb..4aa16172f0 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -148,6 +148,16 @@ "default": false, "description": "Campers is full stack certified" }, + "isChallengeMapMigrated": { + "type": "boolean", + "default": false, + "description": "Migrate completedChallenges array to challenge map" + }, + "challengeMap": { + "type": "object", + "default": {}, + "description": "A map by id of all the user completed challenges" + }, "completedChallenges": { "type": [ { diff --git a/server/middleware.json b/server/middleware.json index fbe4b8801d..6968886d90 100644 --- a/server/middleware.json +++ b/server/middleware.json @@ -47,7 +47,8 @@ "./middlewares/express-rx": {}, "./middlewares/jade-helpers": {}, "./middlewares/global-locals": {}, - "./middlewares/revision-helpers": {} + "./middlewares/revision-helpers": {}, + "./middlewares/migrate-completed-challenges": {} }, "routes": { }, diff --git a/server/middlewares/migrate-completed-challenges.js b/server/middlewares/migrate-completed-challenges.js new file mode 100644 index 0000000000..46ab690aa4 --- /dev/null +++ b/server/middlewares/migrate-completed-challenges.js @@ -0,0 +1,60 @@ +import { Observable, Scheduler } from 'rx'; +import debug from 'debug'; + +const log = debug('freecc:migrate'); + +// buildChallengeMap( +// userId: String, +// completedChallenges: Object[], +// User: User +// ) => Observable +function buildChallengeMap(userId, completedChallenges = [], User) { + return Observable.from( + completedChallenges, + null, + null, + Scheduler.default + ) + .reduce((challengeMap, challenge) => { + const id = challenge.id || challenge._id; + challenge = challenge && typeof challenge.toJSON === 'function' ? + challenge.toJSON() : + challenge; + + challengeMap[id] = challenge; + return challengeMap; + }, {}) + .flatMap(challengeMap => { + const updateData = { + '$set': { + challengeMap, + isChallengeMapMigrated: true + } + }; + return Observable.fromNodeCallback(User.updateAll, User)( + { id: userId }, + updateData, + { allowExtendedOperators: true } + ); + }); +} + +export default function migrateCompletedChallenges() { + return ({ user, app }, res, next) => { + const User = app.models.User; + if (!user || user.isChallengeMapMigrated) { + return next(); + } + return buildChallengeMap( + user.id.toString(), + user.completedChallenges, + User + ) + .subscribe( + count => log('documents update', count), + // errors go here + next, + next + ); + }; +} From 76cfbdf752ab885d7bdaa89a70d287706dabb98f Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Tue, 9 Feb 2016 14:33:25 -0800 Subject: [PATCH 02/14] challenge/user router now works with challengeMap --- common/models/user.js | 23 ++++ server/boot/challenge.js | 144 +++++++++---------- server/boot/user.js | 245 ++++++++++++++------------------- server/views/account/show.jade | 4 +- 4 files changed, 204 insertions(+), 212 deletions(-) diff --git a/common/models/user.js b/common/models/user.js index 9f82a86ea3..cc81e01fa9 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -52,6 +52,12 @@ module.exports = function(User) { User.validatesUniquenessOf('username'); User.settings.emailVerificationRequired = false; + User.on('dataSourceAttached', () => { + User.findOne$ = Observable.fromNodeCallback(User.findOne, User); + User.update$ = Observable.fromNodeCallback(User.updateAll, User); + User.count$ = Observable.fromNodeCallback(User.count, User); + }); + User.observe('before save', function({ instance: user }, next) { if (user) { user.username = user.username.trim().toLowerCase(); @@ -416,4 +422,21 @@ module.exports = function(User) { } } ); + + // user.updateTo$(updateData: Object) => Observable[Number] + User.prototype.update$ = function update$(updateData) { + const id = this.getId(); + const updateOptions = { allowExtendedOperators: true }; + if ( + !updateData || + typeof updateData !== 'object' || + Object.keys(updateData).length > 0 + ) { + return Observable.throw(new Error( + `updateData must be an object with at least on key, + but got ${updateData}`.split('\n').join(' ') + )); + } + return this.constructor.update$({ id }, updateData, updateOptions); + }; }; diff --git a/server/boot/challenge.js b/server/boot/challenge.js index 70c0534969..1f7fda0d4d 100644 --- a/server/boot/challenge.js +++ b/server/boot/challenge.js @@ -2,7 +2,7 @@ import _ from 'lodash'; import dedent from 'dedent'; import moment from 'moment'; import { Observable, Scheduler } from 'rx'; -import debugFactory from 'debug'; +import debug from 'debug'; import accepts from 'accepts'; import { @@ -14,7 +14,7 @@ import { randomCompliment } from '../utils'; -import { saveUser, observeMethod } from '../utils/rx'; +import { observeMethod } from '../utils/rx'; import { ifNoUserSend @@ -24,7 +24,7 @@ import getFromDisk$ from '../utils/getFromDisk$'; const isDev = process.env.NODE_ENV !== 'production'; const isBeta = !!process.env.BETA; -const debug = debugFactory('freecc:challenges'); +const log = debug('freecc:challenges'); const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i; const challengeView = { 0: 'challenges/showHTML', @@ -40,8 +40,7 @@ function isChallengeCompleted(user, challengeId) { if (!user) { return false; } - return user.completedChallenges.some(challenge => - challenge.id === challengeId ); + return user.challengeMap[challengeId]; } /* @@ -50,36 +49,53 @@ function numberWithCommas(x) { } */ -function updateUserProgress(user, challengeId, completedChallenge) { - let { completedChallenges } = user; +function buildUserUpdate( + user, + challengeId, + completedChallenge, + timezone +) { + const updateData = { $set: {} }; + let finalChallenge; + const { timezone: userTimezone, challengeMap = {} } = user; - const indexOfChallenge = _.findIndex(completedChallenges, { - id: challengeId - }); + const oldChallenge = challengeMap[challengeId]; + const alreadyCompleted = !!oldChallenge; - const alreadyCompleted = indexOfChallenge !== -1; - if (!alreadyCompleted) { - user.progressTimestamps.push({ + if (alreadyCompleted) { + // add data from old challenge + finalChallenge = { + ...completedChallenge, + completedDate: oldChallenge.completedDate, + lastUpdated: completedChallenge.completedDate + }; + + updateData.$push = { timestamp: Date.now(), completedChallenge: challengeId - }); - user.completedChallenges.push(completedChallenge); - return user; + }; + } else { + finalChallenge = completedChallenge; } - const oldCompletedChallenge = completedChallenges[indexOfChallenge]; - user.completedChallenges[indexOfChallenge] = - Object.assign( - {}, - completedChallenge, - { - completedDate: oldCompletedChallenge.completedDate, - lastUpdated: completedChallenge.completedDate - } - ); + updateData.$set = { + [`challengeMap.${challengeId}`]: finalChallenge + }; - return { user, alreadyCompleted }; + if ( + timezone !== 'UTC' && + (!userTimezone || userTimezone === 'UTC') + ) { + updateData.$set = { + ...updateData.$set, + timezone: userTimezone + }; + } + + log('user update data', updateData); + + return { alreadyCompleted, updateData }; } @@ -117,7 +133,7 @@ function getRenderData$(user, challenge$, origChallengeName, solution) { .replace(challengesRegex, ''); const testChallengeName = new RegExp(challengeName, 'i'); - debug('looking for %s', testChallengeName); + log('looking for %s', testChallengeName); return challenge$ .map(challenge => challenge.toJSON()) @@ -136,7 +152,7 @@ function getRenderData$(user, challenge$, origChallengeName, solution) { // Handle not found if (!challenge) { - debug('did not find challenge for ' + origChallengeName); + log('did not find challenge for ' + origChallengeName); return Observable.just({ type: 'redirect', redirectUrl: '/map', @@ -187,25 +203,21 @@ function getRenderData$(user, challenge$, origChallengeName, solution) { }); } -function getCompletedChallengeIds(user = {}) { - // if user - // get the id's of all the users completed challenges - return !user.completedChallenges ? - [] : - _.uniq(user.completedChallenges) - .map(({ id, _id }) => id || _id); -} - // create a stream of an array of all the challenge blocks -function getSuperBlocks$(challenge$, completedChallenges) { +function getSuperBlocks$(challenge$, challengeMap) { return challenge$ // mark challenge completed .map(challengeModel => { const challenge = challengeModel.toJSON(); - if (completedChallenges.indexOf(challenge.id) !== -1) { - challenge.completed = true; - } + challenge.completed = !!challengeMap[challenge.id]; challenge.markNew = shouldShowNew(challenge); + + if (challenge.type === 'hike') { + challenge.url = '/videos/' + challenge.dashedName; + } else { + challenge.url = '/challenges/' + challenge.dashedName; + } + return challenge; }) // group challenges by block | returns a stream of observables @@ -223,15 +235,6 @@ function getSuperBlocks$(challenge$, completedChallenges) { const isComingSoon = _.every(blockArray, 'isComingSoon'); const isRequired = _.every(blockArray, 'isRequired'); - blockArray = blockArray.map(challenge => { - if (challenge.challengeType == 6 && challenge.type === 'hike') { - challenge.url = '/videos/' + challenge.dashedName; - } else { - challenge.url = '/challenges/' + challenge.dashedName; - } - return challenge; - }); - return { isBeta, isComingSoon, @@ -428,7 +431,7 @@ module.exports = function(app) { getChallengeById$(challenge$, challengeId) .doOnNext(({ dashedName })=> { if (!dashedName) { - debug('no challenge found for %s', challengeId); + log('no challenge found for %s', challengeId); req.flash('info', { msg: `We coudn't find a challenge with the id ${challengeId}` }); @@ -473,7 +476,7 @@ module.exports = function(app) { return getNextChallenge$(challenge$, blocks$, challengeId) .doOnNext(({ dashedName } = {}) => { if (!dashedName) { - debug('no challenge found for %s', challengeId); + log('no challenge found for %s', challengeId); res.redirect('/map'); } res.redirect('/challenges/' + dashedName); @@ -495,7 +498,7 @@ module.exports = function(app) { }); } if (type === 'redirect') { - debug('redirecting to %s', redirectUrl); + log('redirecting to %s', redirectUrl); return res.redirect(redirectUrl); } var view = challengeView[data.challengeType]; @@ -521,7 +524,7 @@ module.exports = function(app) { timezone } = req.body; - const { alreadyCompleted } = updateUserProgress( + const { alreadyCompleted, updateData } = buildUserUpdate( req.user, id, { @@ -529,20 +532,16 @@ module.exports = function(app) { challengeType, solution, name, - completedDate, - verified: true - } + completedDate + }, + timezone ); - if (timezone && (!req.user.timezone || req.user.timezone !== timezone)) { - req.user.timezone = timezone; - } - - let user = req.user; - saveUser(req.user) + const user = req.user; + return user.updateTo$(updateData) + .doOnNext(count => log('%s documents updated', count)) .subscribe( - function(user) { - user = user; + function() { }, next, function() { @@ -558,7 +557,7 @@ module.exports = function(app) { } function completedZiplineOrBasejump(req, res, next) { - const { body = {} } = req; + const { user, body = {} } = req; let completedChallenge; // backwards compatibility @@ -606,16 +605,19 @@ module.exports = function(app) { } - updateUserProgress(req.user, completedChallenge.id, completedChallenge); + const { + updateData + } = buildUserUpdate(req.user, completedChallenge.id, completedChallenge); - return saveUser(req.user) + return user.updateTo$(updateData) .doOnNext(() => res.status(200).send(true)) .subscribe(() => {}, next); } - function showMap(showAside, { user }, res, next) { + function showMap(showAside, { user = {} }, res, next) { + const { challengeMap = {} } = user; - getSuperBlocks$(challenge$, getCompletedChallengeIds(user)) + return getSuperBlocks$(challenge$, challengeMap) .subscribe( superBlocks => { res.render('map/show', { diff --git a/server/boot/user.js b/server/boot/user.js index 1b1eddc3ea..48e7cad301 100644 --- a/server/boot/user.js +++ b/server/boot/user.js @@ -1,4 +1,3 @@ -import _ from 'lodash'; import dedent from 'dedent'; import moment from 'moment-timezone'; import { Observable } from 'rx'; @@ -60,6 +59,61 @@ function encodeFcc(value = '') { return replaceScriptTags(replaceFormAction(value)); } +function isAlgorithm(challenge) { + // test if name starts with hike/waypoint/basejump/zipline + // fix for bug that saved different challenges with incorrect + // challenge types + return !(/^(waypoint|hike|zipline|basejump)/i).test(challenge.name) && + +challenge.challengeType === 5; +} + +function isProject(challenge) { + return +challenge.challengeType === 3 || + +challenge.challengeType === 4; +} + +function getChallengeGroup(challenge) { + if (isProject(challenge)) { + return 'projects'; + } else if (isAlgorithm(challenge)) { + return 'algorithms'; + } + return 'challenges'; +} + +// buildDisplayChallenges(challengeMap: Object, tz: String) => Observable[{ +// algorithms: Array, +// projects: Array, +// challenges: Array +// }] +function buildDisplayChallenges(challengeMap = {}, timezone) { + return Observable.from(Object.keys(challengeMap)) + .map(challengeId => challengeMap[challengeId]) + .map(challenge => { + let finalChallenge = { ...challenge }; + if (challenge.completedDate) { + finalChallenge.completedDate = moment + .tz(challenge.completedDate, timezone) + .format(dateFormat); + } + + if (challenge.lastUpdated) { + finalChallenge.lastUpdated = moment + .tz(challenge.lastUpdated, timezone) + .format(dateFormat); + } + + return finalChallenge; + }) + .groupBy(getChallengeGroup) + .flatMap(group$ => { + return group$.toArray().map(challenges => ({ + [getChallengeGroup(challenges[0])]: challenges + })); + }) + .reduce((output, group) => ({ ...output, ...group}), {}); +} + module.exports = function(app) { var router = app.loopback.Router(); var User = app.models.User; @@ -170,33 +224,35 @@ module.exports = function(app) { function returnUser(req, res, next) { const username = req.params.username.toLowerCase(); - const { path } = req; - User.findOne( - { - where: { username }, - include: 'pledge' - }, - function(err, profileUser) { - if (err) { - return next(err); - } - if (!profileUser) { + const { user, path } = req; + + // timezone of signed-in account + // to show all date related components + // using signed-in account's timezone + // not of the profile she is viewing + const timezone = user && user.timezone ? + user.timezone : + 'UTC'; + + const query = { + where: { username }, + include: 'pledge' + }; + + return User.findOne$(query) + .filter(userPortfolio => { + if (!userPortfolio) { req.flash('errors', { - msg: `404: We couldn't find path ${ path }` + msg: `We couldn't find a page for ${ path }` }); - console.log('404'); - return res.redirect('/'); + res.redirect('/'); } - profileUser = profileUser.toJSON(); + return !!userPortfolio; + }) + .flatMap(userPortfolio => { + userPortfolio = userPortfolio.toJSON(); - // timezone of signed-in account - // to show all date related components - // using signed-in account's timezone - // not of the profile she is viewing - const timezone = req.user && - req.user.timezone ? req.user.timezone : 'UTC'; - - const timestamps = profileUser + const timestamps = userPortfolio .progressTimestamps .map(objOrNum => { return typeof objOrNum === 'number' ? @@ -206,10 +262,10 @@ module.exports = function(app) { const uniqueDays = prepUniqueDays(timestamps, timezone); - profileUser.currentStreak = calcCurrentStreak(uniqueDays, timezone); - profileUser.longestStreak = calcLongestStreak(uniqueDays, timezone); + userPortfolio.currentStreak = calcCurrentStreak(uniqueDays, timezone); + userPortfolio.longestStreak = calcLongestStreak(uniqueDays, timezone); - const data = profileUser + const calender = userPortfolio .progressTimestamps .map((objOrNum) => { return typeof objOrNum === 'number' ? @@ -224,89 +280,30 @@ module.exports = function(app) { return data; }, {}); - function filterAlgos(challenge) { - // test if name starts with hike/waypoint/basejump/zipline - // fix for bug that saved different challenges with incorrect - // challenge types - return !(/^(waypoint|hike|zipline|basejump)/i).test(challenge.name) && - +challenge.challengeType === 5; - } - - function filterProjects(challenge) { - return +challenge.challengeType === 3 || - +challenge.challengeType === 4; - } - - const completedChallenges = profileUser.completedChallenges - .filter(({ name }) => typeof name === 'string') - .map(challenge => { - challenge = { ...challenge }; - if (challenge.completedDate) { - challenge.completedDate = - moment.tz(challenge.completedDate, timezone) - .format(dateFormat); - } - if (challenge.lastUpdated) { - challenge.lastUpdated = - moment.tz(challenge.lastUpdated, timezone).format(dateFormat); - } - return challenge; - }); - - const projects = completedChallenges.filter(filterProjects); - - const algos = completedChallenges.filter(filterAlgos); - - const challenges = completedChallenges - .filter(challenge => !filterAlgos(challenge)) - .filter(challenge => !filterProjects(challenge)); - - res.render('account/show', { - title: 'Camper ' + profileUser.username + '\'s Code Portfolio', - username: profileUser.username, - name: profileUser.name, - - isMigrationGrandfathered: profileUser.isMigrationGrandfathered, - isGithubCool: profileUser.isGithubCool, - isLocked: !!profileUser.isLocked, - - pledge: profileUser.pledge, - - isFrontEndCert: profileUser.isFrontEndCert, - isDataVisCert: profileUser.isDataVisCert, - isBackEndCert: profileUser.isBackEndCert, - isFullStackCert: profileUser.isFullStackCert, - isHonest: profileUser.isHonest, - - location: profileUser.location, - calender: data, - - github: profileUser.githubURL, - linkedin: profileUser.linkedin, - google: profileUser.google, - facebook: profileUser.facebook, - twitter: profileUser.twitter, - picture: profileUser.picture, - - progressTimestamps: profileUser.progressTimestamps, - - projects, - algos, - challenges, - moment, - - longestStreak: profileUser.longestStreak, - currentStreak: profileUser.currentStreak, - - encodeFcc - }); - } - ); + return buildDisplayChallenges(userPortfolio.challengeMap, timezone) + .map(displayChallenges => ({ + ...userPortfolio, + ...displayChallenges, + title: 'Camper ' + userPortfolio.username + '\'s Code Portfolio', + calender, + github: userPortfolio.githubURL, + moment, + encodeFcc + })); + }) + .doOnNext(data => { + return res.render('account/show', data); + }) + .subscribe( + () => {}, + next + ); } function showCert(certType, req, res, next) { const username = req.params.username.toLowerCase(); const { user } = req; + const certId = certIds[certType]; Observable.just(user) .flatMap(user => { if (user && user.username === username) { @@ -321,9 +318,9 @@ module.exports = function(app) { isBackEndCert: true, isFullStackCert: true, isHonest: true, - completedChallenges: true, username: true, - name: true + name: true, + [ `challengesMap.${certId}` ]: true }); }) .subscribe( @@ -376,15 +373,8 @@ module.exports = function(app) { if (user[certType]) { - // find challenge in user profile - // if not found supply empty object - // if found grab date - // if no date use todays date - var { completedDate = new Date() } = - _.find( - user.completedChallenges, - { id: certIds[certType] } - ) || {}; + const { completedDate = new Date() } = + user.challengeMap[certId] || {}; return res.render( certViews[certType], @@ -511,29 +501,6 @@ module.exports = function(app) { }); } - /* - function updateUserStoryPictures(userId, picture, username, cb) { - Story.find({ 'author.userId': userId }, function(err, stories) { - if (err) { return cb(err); } - - const tasks = []; - stories.forEach(function(story) { - story.author.picture = picture; - story.author.username = username; - tasks.push(function(cb) { - story.save(cb); - }); - }); - async.parallel(tasks, function(err) { - if (err) { - return cb(err); - } - cb(); - }); - }); - } - */ - function vote1(req, res, next) { if (req.user) { req.user.tshirtVote = 1; diff --git a/server/views/account/show.jade b/server/views/account/show.jade index c5f48513f6..8d3e4bc576 100644 --- a/server/views/account/show.jade +++ b/server/views/account/show.jade @@ -140,7 +140,7 @@ block content a(href=challenge.solution, target='_blank') View my project td.col-xs-12.visible-xs a(href=challenge.solution, target='_blank')= removeOldTerms(challenge.name) - if (algos.length > 0) + if (algorithms.length > 0) .col-sm-12 table.table.table-striped thead @@ -149,7 +149,7 @@ block content th.col-xs-2.hidden-xs Completed th.col-xs-2.hidden-xs Last Updated th.col-xs-2.hidden-xs Solution - for challenge in algos + for challenge in algorithms tr td.col-xs-5.hidden-xs= removeOldTerms(challenge.name) td.col-xs-2.hidden-xs= challenge.completedDate ? challenge.completedDate : 'Not Available' From dc27f53ecb0db5a29e122325da6d4a59afdd4236 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Tue, 9 Feb 2016 20:54:49 -0800 Subject: [PATCH 03/14] Commit uses challengeMap --- server/boot/certificate.js | 78 ++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 46 deletions(-) diff --git a/server/boot/certificate.js b/server/boot/certificate.js index 8f231f576b..b8e46428f4 100644 --- a/server/boot/certificate.js +++ b/server/boot/certificate.js @@ -1,17 +1,14 @@ import _ from 'lodash'; import dedent from 'dedent'; import { Observable } from 'rx'; -import debugFactory from 'debug'; +import debug from 'debug'; import { ifNoUser401, ifNoUserSend } from '../utils/middleware'; -import { - saveUser, - observeQuery -} from '../utils/rx'; +import { observeQuery } from '../utils/rx'; import { frontEndChallengeId, @@ -25,17 +22,13 @@ import { import certTypes from '../utils/certTypes.json'; -const debug = debugFactory('freecc:certification'); +const log = debug('freecc:certification'); const sendMessageToNonUser = ifNoUserSend( 'must be logged in to complete.' ); -function isCertified(ids, { completedChallenges }) { - return _.every(ids, ({ id }) => { - return _.some(completedChallenges, (challenge) => { - return challenge.id === id || challenge._id === id; - }); - }); +function isCertified(ids, challengeMap = {}) { + return _.every(ids, ({ id }) => challengeMap[id]); } function getIdsForCert$(id, Challenge) { @@ -90,12 +83,9 @@ export default function certificate(app) { app.use(router); function verifyCert(certType, req, res, next) { - Observable.just({}) - .flatMap(() => { - return certTypeIds[certType]; - }) + const { user } = req; + return certTypeIds[certType]() .flatMap(challenge => { - const { user } = req; const { id, tests, @@ -104,38 +94,39 @@ export default function certificate(app) { } = challenge; if ( !user[certType] && - isCertified(tests, user) + isCertified(tests, user.challengeMap) ) { - user[certType] = true; - user.completedChallenges.push({ - id, - name, - completedDate: new Date(), - challengeType - }); + const updateData = { + $set: { + [`challengeMap.${id}`]: { + id, + name, + completedDate: new Date(), + challengeType + }, + [certType]: true + } + }; - return saveUser(user) + return req.user.udate$(updateData) // If user has commited to nonprofit, // this will complete his pledge .flatMap( - user => completeCommitment$(user), - (user, pledgeOrMessage) => { + () => completeCommitment$(user), + ({ count }, pledgeOrMessage) => { if (typeof pledgeOrMessage === 'string') { - debug(pledgeOrMessage); + log(pledgeOrMessage); } - // we are only interested in the user object - // so we ignore return from completeCommitment$ - return user; + log(`${count} documents updated`); + return true; } ); } - return Observable.just(user); + return Observable.just(false); }) .subscribe( - user => { - if ( - user[certType] - ) { + (didCertify) => { + if (didCertify) { return res.status(200).send(true); } return res.status(200).send( @@ -150,14 +141,9 @@ export default function certificate(app) { } function postHonest(req, res, next) { - const { user } = req; - user.isHonest = true; - saveUser(user) - .subscribe( - (user) => { - res.status(200).send(!!user.isHonest); - }, - next - ); + return req.user.update$({ $set: { isHonest: true } }).subscribe( + () => res.status(200).send(true), + next + ); } } From a4dd9667ca8a5db2c858a9800a3d5b016b12aab6 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 10 Feb 2016 10:05:51 -0800 Subject: [PATCH 04/14] Fix typos --- common/models/user.js | 8 +++++--- server/boot/certificate.js | 4 ++-- server/boot/challenge.js | 13 ++++++++----- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/common/models/user.js b/common/models/user.js index cc81e01fa9..6d45beb6c2 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -430,11 +430,13 @@ module.exports = function(User) { if ( !updateData || typeof updateData !== 'object' || - Object.keys(updateData).length > 0 + !Object.keys(updateData).length ) { return Observable.throw(new Error( - `updateData must be an object with at least on key, - but got ${updateData}`.split('\n').join(' ') + dedent` + updateData must be an object with at least one key, + but got ${updateData} with ${Object.keys(updateData).length} + `.split('\n').join(' ') )); } return this.constructor.update$({ id }, updateData, updateOptions); diff --git a/server/boot/certificate.js b/server/boot/certificate.js index b8e46428f4..e4082e5f74 100644 --- a/server/boot/certificate.js +++ b/server/boot/certificate.js @@ -84,7 +84,7 @@ export default function certificate(app) { function verifyCert(certType, req, res, next) { const { user } = req; - return certTypeIds[certType]() + return certTypeIds[certType] .flatMap(challenge => { const { id, @@ -108,7 +108,7 @@ export default function certificate(app) { } }; - return req.user.udate$(updateData) + return req.user.update$(updateData) // If user has commited to nonprofit, // this will complete his pledge .flatMap( diff --git a/server/boot/challenge.js b/server/boot/challenge.js index 1f7fda0d4d..aade450945 100644 --- a/server/boot/challenge.js +++ b/server/boot/challenge.js @@ -84,6 +84,7 @@ function buildUserUpdate( }; if ( + timezone && timezone !== 'UTC' && (!userTimezone || userTimezone === 'UTC') ) { @@ -538,16 +539,18 @@ module.exports = function(app) { ); const user = req.user; - return user.updateTo$(updateData) - .doOnNext(count => log('%s documents updated', count)) + const points = alreadyCompleted ? + user.progressTimestamps.length : + user.progressTimestamps.length + 1; + return user.update$(updateData) + .doOnNext(({ count }) => log('%s documents updated', count)) .subscribe( - function() { - }, + () => {}, next, function() { if (type === 'json') { return res.json({ - points: user.progressTimestamps.length, + points, alreadyCompleted }); } From d23f7fa8f281137717013dd7b1e55b1242c1d401 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 10 Feb 2016 10:35:40 -0800 Subject: [PATCH 05/14] Fix isChallengeCompleted not returning a boolean --- server/boot/challenge.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/boot/challenge.js b/server/boot/challenge.js index aade450945..67c4dbbf8d 100644 --- a/server/boot/challenge.js +++ b/server/boot/challenge.js @@ -40,7 +40,7 @@ function isChallengeCompleted(user, challengeId) { if (!user) { return false; } - return user.challengeMap[challengeId]; + return !!user.challengeMap[challengeId]; } /* From 80d26ed9b12a21cecb139550d091d763c8ff1c10 Mon Sep 17 00:00:00 2001 From: Rex Schrader Date: Wed, 10 Feb 2016 12:01:00 -0800 Subject: [PATCH 06/14] Fix progressTimestamps Update --- server/boot/challenge.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server/boot/challenge.js b/server/boot/challenge.js index 67c4dbbf8d..ca222579ad 100644 --- a/server/boot/challenge.js +++ b/server/boot/challenge.js @@ -70,12 +70,13 @@ function buildUserUpdate( completedDate: oldChallenge.completedDate, lastUpdated: completedChallenge.completedDate }; - - updateData.$push = { - timestamp: Date.now(), - completedChallenge: challengeId - }; } else { + updateData.$push = { + progressTimestamps: { + timestamp: Date.now(), + completedChallenge: challengeId + } + }; finalChallenge = completedChallenge; } From ff349642645f92b60bd59c9969f754b1886a0c17 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 10 Feb 2016 13:26:32 -0800 Subject: [PATCH 07/14] Update completed challenge names --- .../migrate-completed-challenges.js | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/server/middlewares/migrate-completed-challenges.js b/server/middlewares/migrate-completed-challenges.js index 46ab690aa4..6fe32da489 100644 --- a/server/middlewares/migrate-completed-challenges.js +++ b/server/middlewares/migrate-completed-challenges.js @@ -2,6 +2,53 @@ import { Observable, Scheduler } from 'rx'; import debug from 'debug'; const log = debug('freecc:migrate'); +const challengeTypes = { + html: 0, + js: 1, + video: 2, + zipline: 3, + basejump: 4, + bonfire: 5, + hikes: 6, + step: 7, + waypoint: 0 +}; + +const challengeTypeReg = /^(waypoint|hike|zipline|basejump)/i; +const challengeTypeRegWithColon = + /^(bonfire|checkpoint|waypoint|hike|zipline|basejump):\s+/i; + +function updateName(challenge) { + challenge = challenge && typeof challenge.toJSON === 'function' ? + challenge.toJSON() : + challenge; + + if ( + challenge.name && + challenge.challengeType === 5 && + challengeTypeReg.test(challenge.name) + ) { + + challenge.name.replace(challengeTypeReg, match => { + // find the correct type + const type = challengeTypes[''.toLowerCase.call(match)]; + // if type found, replace current type + // + if (type) { + challenge.challengeType = type; + } + + return match; + }); + + } + + if (challenge.name) { + challenge.oldName = challenge.name; + challenge.name = challenge.name.replace(challengeTypeRegWithColon, ''); + } + return challenge; +} // buildChallengeMap( // userId: String, @@ -15,11 +62,9 @@ function buildChallengeMap(userId, completedChallenges = [], User) { null, Scheduler.default ) + .map(updateName) .reduce((challengeMap, challenge) => { const id = challenge.id || challenge._id; - challenge = challenge && typeof challenge.toJSON === 'function' ? - challenge.toJSON() : - challenge; challengeMap[id] = challenge; return challengeMap; From 0542845b10395366212b91c66ef53b4cdadc6ec2 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 10 Feb 2016 14:32:14 -0800 Subject: [PATCH 08/14] Filter out bad id's --- server/middlewares/migrate-completed-challenges.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/middlewares/migrate-completed-challenges.js b/server/middlewares/migrate-completed-challenges.js index 6fe32da489..67b6d721d2 100644 --- a/server/middlewares/migrate-completed-challenges.js +++ b/server/middlewares/migrate-completed-challenges.js @@ -1,4 +1,5 @@ import { Observable, Scheduler } from 'rx'; +import { ObjectID } from 'mongodb'; import debug from 'debug'; const log = debug('freecc:migrate'); @@ -62,6 +63,7 @@ function buildChallengeMap(userId, completedChallenges = [], User) { null, Scheduler.default ) + .filter(({ id, _id }) => ObjectID.isValid(id || _id)) .map(updateName) .reduce((challengeMap, challenge) => { const id = challenge.id || challenge._id; From 858546e3ec99b9ba292788f57ce4836a79a70d62 Mon Sep 17 00:00:00 2001 From: Rex Schrader Date: Wed, 10 Feb 2016 15:18:48 -0800 Subject: [PATCH 09/14] Migrate invalid Challenge IDs --- .../basic-javascript.json | 20 ++++++------- .../json-apis-and-ajax.json | 16 +++++------ .../migrate-completed-challenges.js | 28 +++++++++++++++++++ 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/seed/challenges/01-front-end-development-certification/basic-javascript.json b/seed/challenges/01-front-end-development-certification/basic-javascript.json index 87c920c92d..6a4c302b7f 100644 --- a/seed/challenges/01-front-end-development-certification/basic-javascript.json +++ b/seed/challenges/01-front-end-development-certification/basic-javascript.json @@ -1462,7 +1462,7 @@ ] }, { - "id": "bg9997c9c79feddfaeb9bdef", + "id": "56bbb991ad1ed5201cd392ca", "title": "Access Array Data with Indexes", "description": [ "We can access the data inside arrays using indexes.", @@ -1581,7 +1581,7 @@ "challengeType": 1 }, { - "id": "bg9995c9c69feddfaeb9bdef", + "id": "56bbb991ad1ed5201cd392cb", "title": "Manipulate Arrays With push()", "description": [ "An easy way to append data to the end of an array is via the push() function.", @@ -1621,7 +1621,7 @@ ] }, { - "id": "bg9994c9c69feddfaeb9bdef", + "id": "56bbb991ad1ed5201cd392cc", "title": "Manipulate Arrays With pop()", "description": [ "Another way to change the data in an array is with the .pop() function.", @@ -1666,7 +1666,7 @@ ] }, { - "id": "bg9996c9c69feddfaeb9bdef", + "id": "56bbb991ad1ed5201cd392cd", "title": "Manipulate Arrays With shift()", "description": [ "pop() always removes the last element of an array. What if you want to remove the first?", @@ -1708,7 +1708,7 @@ ] }, { - "id": "bg9997c9c69feddfaeb9bdef", + "id": "56bbb991ad1ed5201cd392ce", "title": "Manipulate Arrays With unshift()", "description": [ "Not only can you shift elements off of the beginning of an array, you can also unshift elements to the beginning of an array i.e. add elements in front of the array.", @@ -1805,7 +1805,7 @@ "challengeType": 1 }, { - "id": "bg9997c9c89feddfaeb9bdef", + "id": "56bbb991ad1ed5201cd392cf", "title": "Write Reusable JavaScript with Functions", "description": [ "In JavaScript, we can divide up our code into reusable parts called functions.", @@ -3314,7 +3314,7 @@ "challengeType": 1 }, { - "id": "bg9998c9c99feddfaeb9bdef", + "id": "56bbb991ad1ed5201cd392d0", "title": "Build JavaScript Objects", "description": [ "You may have heard the term object before.", @@ -3500,7 +3500,7 @@ "challengeType": 1 }, { - "id": "bg9999c9c99feddfaeb9bdef", + "id": "56bbb991ad1ed5201cd392d1", "title": "Updating Object Properties", "description": [ "After you've created a JavaScript object, you can update its properties at any time just like you would update any other variable. You can use either dot or bracket notation to update.", @@ -3565,7 +3565,7 @@ ] }, { - "id": "bg9999c9c99feedfaeb9bdef", + "id": "56bbb991ad1ed5201cd392d2", "title": "Add New Properties to a JavaScript Object", "description": [ "You can add new properties to existing JavaScript objects the same way you would modify them.", @@ -3621,7 +3621,7 @@ ] }, { - "id": "bg9999c9c99fdddfaeb9bdef", + "id": "56bbb991ad1ed5201cd392d3", "title": "Delete Properties from a JavaScript Object", "description": [ "We can also delete properties from objects like this:", diff --git a/seed/challenges/01-front-end-development-certification/json-apis-and-ajax.json b/seed/challenges/01-front-end-development-certification/json-apis-and-ajax.json index 5ade8d36e6..061ad45f07 100644 --- a/seed/challenges/01-front-end-development-certification/json-apis-and-ajax.json +++ b/seed/challenges/01-front-end-development-certification/json-apis-and-ajax.json @@ -5,7 +5,7 @@ "helpRoom": "Help", "challenges": [ { - "id": "bb000000000000000000001", + "id": "56bbb991ad1ed5201cd392d4", "title": "Trigger Click Events with jQuery", "description": [ "In this section, we'll learn how to get data from APIs. APIs - or Application Programming Interfaces - are tools that computers use to communicate with one another.", @@ -63,13 +63,13 @@ "Nesta sessão, vamos aprender como obter dados de uma API. As APIS - Interface de Programação de Aplicativos - são ferramentas usadas pelos computadores para se comunicarem entre si.", "Também aprenderemos como utilizar o HTML com os dados obtidos de uma API usando uma tecnologia chamada Ajax", "Em primeiro lugar, vamos revir o que faz a função $(document).ready(). Esta função faz com que todo o codigo que esteja dentro de seu escopo execute somente quando a nossa página tenha sido carregada.", - "Vamos fazer nosso butão \"Get message\" mudar o texto do elemento com a classe message.", + "Vamos fazer nosso butão \"Get message\" mudar o texto do elemento com a classe message.", "Antes de poder fazer isso, temos que implementar um evento de clique dentro da nossa função $(document).ready(), adicionando este código:", "
$(\"#getMessage\").on(\"click\", function(){

});
" ] }, { - "id": "bc000000000000000000001", + "id": "56bbb991ad1ed5201cd392d5", "title": "Change Text with Click Events", "description": [ "When our click event happens, we can use Ajax to update an HTML element.", @@ -128,7 +128,7 @@ ] }, { - "id": "bb000000000000000000002", + "id": "56bbb991ad1ed5201cd392d6", "title": "Get JSON with the jQuery getJSON Method", "description": [ "You can also request data from an external source. This is where APIs come into play.", @@ -209,7 +209,7 @@ ] }, { - "id": "bb000000000000000000003", + "id": "56bbb991ad1ed5201cd392d7", "title": "Convert JSON Data to HTML", "description": [ "Now that we're getting data from a JSON API, let's display it in our HTML.", @@ -283,7 +283,7 @@ ] }, { - "id": "bb000000000000000000004", + "id": "56bbb991ad1ed5201cd392d8", "title": "Render Images from Data Sources", "description": [ "We've seen from the last two lessons that each object in our JSON array contains an imageLink key with a value that is the url of a cat's image.", @@ -361,7 +361,7 @@ ] }, { - "id": "bb000000000000000000005", + "id": "56bbb991ad1ed5201cd392d9", "title": "Prefilter JSON", "description": [ "If we don't want to render every cat photo we get from our Free Code Camp's Cat Photo JSON API, we can pre-filter the json before we loop through it.", @@ -440,7 +440,7 @@ ] }, { - "id": "bb000000000000000000006", + "id": "56bbb991ad1ed5201cd392da", "title": "Get Geo-location Data", "description": [ "Another cool thing we can do is access our user's current location. Every browser has a built in navigator that can give us this information.", diff --git a/server/middlewares/migrate-completed-challenges.js b/server/middlewares/migrate-completed-challenges.js index 67b6d721d2..0d2b146733 100644 --- a/server/middlewares/migrate-completed-challenges.js +++ b/server/middlewares/migrate-completed-challenges.js @@ -15,6 +15,26 @@ const challengeTypes = { waypoint: 0 }; +const idMap = { + "bg9997c9c79feddfaeb9bdef": "56bbb991ad1ed5201cd392ca", + "bg9995c9c69feddfaeb9bdef": "56bbb991ad1ed5201cd392cb", + "bg9994c9c69feddfaeb9bdef": "56bbb991ad1ed5201cd392cc", + "bg9996c9c69feddfaeb9bdef": "56bbb991ad1ed5201cd392cd", + "bg9997c9c69feddfaeb9bdef": "56bbb991ad1ed5201cd392ce", + "bg9997c9c89feddfaeb9bdef": "56bbb991ad1ed5201cd392cf", + "bg9998c9c99feddfaeb9bdef": "56bbb991ad1ed5201cd392d0", + "bg9999c9c99feddfaeb9bdef": "56bbb991ad1ed5201cd392d1", + "bg9999c9c99feedfaeb9bdef": "56bbb991ad1ed5201cd392d2", + "bg9999c9c99fdddfaeb9bdef": "56bbb991ad1ed5201cd392d3", + "bb000000000000000000001": "56bbb991ad1ed5201cd392d4", + "bc000000000000000000001": "56bbb991ad1ed5201cd392d5", + "bb000000000000000000002": "56bbb991ad1ed5201cd392d6", + "bb000000000000000000003": "56bbb991ad1ed5201cd392d7", + "bb000000000000000000004": "56bbb991ad1ed5201cd392d8", + "bb000000000000000000005": "56bbb991ad1ed5201cd392d9", + "bb000000000000000000006": "56bbb991ad1ed5201cd392da" +}; + const challengeTypeReg = /^(waypoint|hike|zipline|basejump)/i; const challengeTypeRegWithColon = /^(bonfire|checkpoint|waypoint|hike|zipline|basejump):\s+/i; @@ -51,6 +71,13 @@ function updateName(challenge) { return challenge; } +function updateId(challenge) { + if(idMap.hasOwnProperty(challenge.id)) { + challenge.id = idMap[challenge.id]; + } + return challenge; +} + // buildChallengeMap( // userId: String, // completedChallenges: Object[], @@ -64,6 +91,7 @@ function buildChallengeMap(userId, completedChallenges = [], User) { Scheduler.default ) .filter(({ id, _id }) => ObjectID.isValid(id || _id)) + .map(updateId) .map(updateName) .reduce((challengeMap, challenge) => { const id = challenge.id || challenge._id; From 5be6c4c92f0c9a779a8550c974fcc93864e18db7 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 10 Feb 2016 16:31:59 -0800 Subject: [PATCH 10/14] Fix lint errors --- .../migrate-completed-challenges.js | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/server/middlewares/migrate-completed-challenges.js b/server/middlewares/migrate-completed-challenges.js index 0d2b146733..1aeaa4ec8a 100644 --- a/server/middlewares/migrate-completed-challenges.js +++ b/server/middlewares/migrate-completed-challenges.js @@ -16,23 +16,23 @@ const challengeTypes = { }; const idMap = { - "bg9997c9c79feddfaeb9bdef": "56bbb991ad1ed5201cd392ca", - "bg9995c9c69feddfaeb9bdef": "56bbb991ad1ed5201cd392cb", - "bg9994c9c69feddfaeb9bdef": "56bbb991ad1ed5201cd392cc", - "bg9996c9c69feddfaeb9bdef": "56bbb991ad1ed5201cd392cd", - "bg9997c9c69feddfaeb9bdef": "56bbb991ad1ed5201cd392ce", - "bg9997c9c89feddfaeb9bdef": "56bbb991ad1ed5201cd392cf", - "bg9998c9c99feddfaeb9bdef": "56bbb991ad1ed5201cd392d0", - "bg9999c9c99feddfaeb9bdef": "56bbb991ad1ed5201cd392d1", - "bg9999c9c99feedfaeb9bdef": "56bbb991ad1ed5201cd392d2", - "bg9999c9c99fdddfaeb9bdef": "56bbb991ad1ed5201cd392d3", - "bb000000000000000000001": "56bbb991ad1ed5201cd392d4", - "bc000000000000000000001": "56bbb991ad1ed5201cd392d5", - "bb000000000000000000002": "56bbb991ad1ed5201cd392d6", - "bb000000000000000000003": "56bbb991ad1ed5201cd392d7", - "bb000000000000000000004": "56bbb991ad1ed5201cd392d8", - "bb000000000000000000005": "56bbb991ad1ed5201cd392d9", - "bb000000000000000000006": "56bbb991ad1ed5201cd392da" + bg9997c9c79feddfaeb9bdef: '56bbb991ad1ed5201cd392ca', + bg9995c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cb', + bg9994c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cc', + bg9996c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cd', + bg9997c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392ce', + bg9997c9c89feddfaeb9bdef: '56bbb991ad1ed5201cd392cf', + bg9998c9c99feddfaeb9bdef: '56bbb991ad1ed5201cd392d0', + bg9999c9c99feddfaeb9bdef: '56bbb991ad1ed5201cd392d1', + bg9999c9c99feedfaeb9bdef: '56bbb991ad1ed5201cd392d2', + bg9999c9c99fdddfaeb9bdef: '56bbb991ad1ed5201cd392d3', + bb000000000000000000001: '56bbb991ad1ed5201cd392d4', + bc000000000000000000001: '56bbb991ad1ed5201cd392d5', + bb000000000000000000002: '56bbb991ad1ed5201cd392d6', + bb000000000000000000003: '56bbb991ad1ed5201cd392d7', + bb000000000000000000004: '56bbb991ad1ed5201cd392d8', + bb000000000000000000005: '56bbb991ad1ed5201cd392d9', + bb000000000000000000006: '56bbb991ad1ed5201cd392da' }; const challengeTypeReg = /^(waypoint|hike|zipline|basejump)/i; @@ -72,7 +72,7 @@ function updateName(challenge) { } function updateId(challenge) { - if(idMap.hasOwnProperty(challenge.id)) { + if (idMap.hasOwnProperty(challenge.id)) { challenge.id = idMap[challenge.id]; } return challenge; From 0354eeae16477d0b3e195f82c5baffff10873a06 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 10 Feb 2016 16:34:09 -0800 Subject: [PATCH 11/14] Move idMap to utils so it can be used elsewhere --- .../migrate-completed-challenges.js | 24 +++---------------- server/utils/bad-id-map.js | 19 +++++++++++++++ 2 files changed, 22 insertions(+), 21 deletions(-) create mode 100644 server/utils/bad-id-map.js diff --git a/server/middlewares/migrate-completed-challenges.js b/server/middlewares/migrate-completed-challenges.js index 1aeaa4ec8a..7aec5e0b3c 100644 --- a/server/middlewares/migrate-completed-challenges.js +++ b/server/middlewares/migrate-completed-challenges.js @@ -2,6 +2,8 @@ import { Observable, Scheduler } from 'rx'; import { ObjectID } from 'mongodb'; import debug from 'debug'; +import idMap from '../utils/bad-id-map'; + const log = debug('freecc:migrate'); const challengeTypes = { html: 0, @@ -15,26 +17,6 @@ const challengeTypes = { waypoint: 0 }; -const idMap = { - bg9997c9c79feddfaeb9bdef: '56bbb991ad1ed5201cd392ca', - bg9995c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cb', - bg9994c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cc', - bg9996c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cd', - bg9997c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392ce', - bg9997c9c89feddfaeb9bdef: '56bbb991ad1ed5201cd392cf', - bg9998c9c99feddfaeb9bdef: '56bbb991ad1ed5201cd392d0', - bg9999c9c99feddfaeb9bdef: '56bbb991ad1ed5201cd392d1', - bg9999c9c99feedfaeb9bdef: '56bbb991ad1ed5201cd392d2', - bg9999c9c99fdddfaeb9bdef: '56bbb991ad1ed5201cd392d3', - bb000000000000000000001: '56bbb991ad1ed5201cd392d4', - bc000000000000000000001: '56bbb991ad1ed5201cd392d5', - bb000000000000000000002: '56bbb991ad1ed5201cd392d6', - bb000000000000000000003: '56bbb991ad1ed5201cd392d7', - bb000000000000000000004: '56bbb991ad1ed5201cd392d8', - bb000000000000000000005: '56bbb991ad1ed5201cd392d9', - bb000000000000000000006: '56bbb991ad1ed5201cd392da' -}; - const challengeTypeReg = /^(waypoint|hike|zipline|basejump)/i; const challengeTypeRegWithColon = /^(bonfire|checkpoint|waypoint|hike|zipline|basejump):\s+/i; @@ -90,8 +72,8 @@ function buildChallengeMap(userId, completedChallenges = [], User) { null, Scheduler.default ) - .filter(({ id, _id }) => ObjectID.isValid(id || _id)) .map(updateId) + .filter(({ id, _id }) => ObjectID.isValid(id || _id)) .map(updateName) .reduce((challengeMap, challenge) => { const id = challenge.id || challenge._id; diff --git a/server/utils/bad-id-map.js b/server/utils/bad-id-map.js new file mode 100644 index 0000000000..4c95bee37a --- /dev/null +++ b/server/utils/bad-id-map.js @@ -0,0 +1,19 @@ +export default { + bg9997c9c79feddfaeb9bdef: '56bbb991ad1ed5201cd392ca', + bg9995c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cb', + bg9994c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cc', + bg9996c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392cd', + bg9997c9c69feddfaeb9bdef: '56bbb991ad1ed5201cd392ce', + bg9997c9c89feddfaeb9bdef: '56bbb991ad1ed5201cd392cf', + bg9998c9c99feddfaeb9bdef: '56bbb991ad1ed5201cd392d0', + bg9999c9c99feddfaeb9bdef: '56bbb991ad1ed5201cd392d1', + bg9999c9c99feedfaeb9bdef: '56bbb991ad1ed5201cd392d2', + bg9999c9c99fdddfaeb9bdef: '56bbb991ad1ed5201cd392d3', + bb000000000000000000001: '56bbb991ad1ed5201cd392d4', + bc000000000000000000001: '56bbb991ad1ed5201cd392d5', + bb000000000000000000002: '56bbb991ad1ed5201cd392d6', + bb000000000000000000003: '56bbb991ad1ed5201cd392d7', + bb000000000000000000004: '56bbb991ad1ed5201cd392d8', + bb000000000000000000005: '56bbb991ad1ed5201cd392d9', + bb000000000000000000006: '56bbb991ad1ed5201cd392da' +}; From c2915f9e1427853991828da1f9a210ea0a401c87 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 10 Feb 2016 16:45:57 -0800 Subject: [PATCH 12/14] Convert model instance to POJO earilier in migration change --- server/middlewares/migrate-completed-challenges.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/server/middlewares/migrate-completed-challenges.js b/server/middlewares/migrate-completed-challenges.js index 7aec5e0b3c..a5f7110f86 100644 --- a/server/middlewares/migrate-completed-challenges.js +++ b/server/middlewares/migrate-completed-challenges.js @@ -22,10 +22,6 @@ const challengeTypeRegWithColon = /^(bonfire|checkpoint|waypoint|hike|zipline|basejump):\s+/i; function updateName(challenge) { - challenge = challenge && typeof challenge.toJSON === 'function' ? - challenge.toJSON() : - challenge; - if ( challenge.name && challenge.challengeType === 5 && @@ -57,6 +53,7 @@ function updateId(challenge) { if (idMap.hasOwnProperty(challenge.id)) { challenge.id = idMap[challenge.id]; } + return challenge; } @@ -72,6 +69,11 @@ function buildChallengeMap(userId, completedChallenges = [], User) { null, Scheduler.default ) + .map(challenge => { + return challenge && typeof challenge.toJSON === 'function' ? + challenge.toJSON() : + challenge; + }) .map(updateId) .filter(({ id, _id }) => ObjectID.isValid(id || _id)) .map(updateName) From d8ad4a59eb796d36956c775a03c724d9ff97605c Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 10 Feb 2016 17:28:45 -0800 Subject: [PATCH 13/14] Make sure projects are always available --- server/boot/user.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/boot/user.js b/server/boot/user.js index 48e7cad301..5edcf506c3 100644 --- a/server/boot/user.js +++ b/server/boot/user.js @@ -111,7 +111,12 @@ function buildDisplayChallenges(challengeMap = {}, timezone) { [getChallengeGroup(challenges[0])]: challenges })); }) - .reduce((output, group) => ({ ...output, ...group}), {}); + .reduce((output, group) => ({ ...output, ...group}), {}) + .map(groups => ({ + algorithms: groups.algorithms || [], + projects: groups.projects || [], + challenges: groups.challenges || [] + })); } module.exports = function(app) { From 6642dd497f4c83b0146441e4294884de14a09bf7 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 10 Feb 2016 22:10:06 -0800 Subject: [PATCH 14/14] Add validation to challenge completion Change ajax requests to send and accept JSON to preserve data types. Fix typos --- client/commonFramework/bindings.js | 20 ++++- client/commonFramework/show-completion.js | 28 ++++--- client/commonFramework/step-challenge.js | 47 +++++++----- server/boot/challenge.js | 94 +++++++++++++++-------- server/middlewares/validator.js | 20 +++-- 5 files changed, 138 insertions(+), 71 deletions(-) diff --git a/client/commonFramework/bindings.js b/client/commonFramework/bindings.js index dc36502f67..0c4d5b6fc4 100644 --- a/client/commonFramework/bindings.js +++ b/client/commonFramework/bindings.js @@ -81,9 +81,15 @@ window.common = (function(global) { data = { id: common.challengeId, name: common.challengeName, - challengeType: common.challengeType + challengeType: +common.challengeType }; - $.post('/completed-challenge/', data) + $.ajax({ + url: '/completed-challenge/', + type: 'POST', + data: JSON.stringify(data), + contentType: 'application/json', + dataType: 'json' + }) .success(function(res) { if (!res) { return; @@ -92,7 +98,7 @@ window.common = (function(global) { common.challengeId; }) .fail(function() { - window.location.href = '/challenges'; + window.location.replace(window.location.href); }); break; @@ -106,7 +112,13 @@ window.common = (function(global) { githubLink }; - $.post('/completed-zipline-or-basejump/', data) + $.ajax({ + url: '/completed-zipline-or-basejump/', + type: 'POST', + data: JSON.stringify(data), + contentType: 'application/json', + dataType: 'json' + }) .success(function() { window.location.href = '/challenges/next-challenge?id=' + common.challengeId; diff --git a/client/commonFramework/show-completion.js b/client/commonFramework/show-completion.js index f579f4626d..86d852a660 100644 --- a/client/commonFramework/show-completion.js +++ b/client/commonFramework/show-completion.js @@ -57,21 +57,31 @@ window.common = (function(global) { `; console.error(err); } - const data = { + const data = JSON.stringify({ id: common.challengeId, name: common.challengeName, completedWith: didCompleteWith, - challengeType: common.challengeType, + challengeType: +common.challengeType, solution, timezone - }; - - $.post('/completed-challenge/', data, function(res) { - if (res) { - window.location = - '/challenges/next-challenge?id=' + common.challengeId; - } }); + + $.ajax({ + url: '/completed-challenge/', + type: 'POST', + data, + contentType: 'application/json', + dataType: 'json' + }) + .success(function(res) { + if (res) { + window.location = + '/challenges/next-challenge?id=' + common.challengeId; + } + }) + .fail(function() { + window.location.replace(window.location.href); + }); }); }; diff --git a/client/commonFramework/step-challenge.js b/client/commonFramework/step-challenge.js index fc0378d883..15ee010f2d 100644 --- a/client/commonFramework/step-challenge.js +++ b/client/commonFramework/step-challenge.js @@ -149,38 +149,45 @@ window.common = (function({ $, common = { init: [] }}) { e.preventDefault(); $('#submit-challenge') - .attr('disabled', 'true') - .removeClass('btn-primary') - .addClass('btn-warning disabled'); + .attr('disabled', 'true') + .removeClass('btn-primary') + .addClass('btn-warning disabled'); var $checkmarkContainer = $('#checkmark-container'); $checkmarkContainer.css({ height: $checkmarkContainer.innerHeight() }); $('#challenge-checkmark') - .addClass('zoomOutUp') - .delay(1000) - .queue(function(next) { - $(this).replaceWith( - '
' + - 'submitting...
' - ); - next(); - }); + .addClass('zoomOutUp') + .delay(1000) + .queue(function(next) { + $(this).replaceWith( + '
' + + 'submitting...
' + ); + next(); + }); - $.post( - '/completed-challenge/', { + $.ajax({ + url: '/completed-challenge/', + type: 'POST', + data: JSON.stringify({ id: common.challengeId, name: common.challengeName, - challengeType: common.challengeType - }, - function(res) { + challengeType: +common.challengeType + }), + contentType: 'application/json', + dataType: 'json' + }) + .success(function(res) { if (res) { window.location = '/challenges/next-challenge?id=' + common.challengeId; } - } - ); + }) + .fail(function() { + window.location.replace(window.location.href); + }); } common.init.push(function($) { diff --git a/server/boot/challenge.js b/server/boot/challenge.js index ca222579ad..33f1418f5b 100644 --- a/server/boot/challenge.js +++ b/server/boot/challenge.js @@ -515,8 +515,28 @@ module.exports = function(app) { } function completedChallenge(req, res, next) { + req.checkBody('id', 'id must be a ObjectId').isMongoId(); + + req.checkBody('name', 'name must be at least 3 characters') + .isString() + .isLength({ min: 3 }); + + req.checkBody('challengeType', 'challengeType must be an integer') + .isNumber() + .isInt(); const type = accepts(req).type('html', 'json', 'text'); + const errors = req.validationErrors(true); + + if (errors) { + if (type === 'json') { + return res.status(403).send({ errors }); + } + + log('errors', errors); + return res.sendStatus(403); + } + const completedDate = Date.now(); const { id, @@ -543,6 +563,7 @@ module.exports = function(app) { const points = alreadyCompleted ? user.progressTimestamps.length : user.progressTimestamps.length + 1; + return user.update$(updateData) .doOnNext(({ count }) => log('%s documents updated', count)) .subscribe( @@ -561,37 +582,34 @@ module.exports = function(app) { } function completedZiplineOrBasejump(req, res, next) { + const type = accepts(req).type('html', 'json', 'text'); + req.checkBody('id', 'id must be an ObjectId').isMongoId(); + req.checkBody('name', 'Name must be at least 3 characters') + .isString() + .isLength({ min: 3 }); + req.checkBody('challengeType', 'must be a number') + .isNumber() + .isInt(); + req.checkBody('solution', 'solution must be a url').isURL(); + + const errors = req.validationErrors(true); + + if (errors) { + if (type === 'json') { + return res.status(403).send({ errors }); + } + log('errors', errors); + return res.sendStatus(403); + } + const { user, body = {} } = req; - let completedChallenge; - // backwards compatibility - // please remove once in production - // to allow users to transition to new client code - if (body.challengeInfo) { - - if (!body.challengeInfo.challengeId) { - req.flash('error', { msg: 'No id returned during save' }); - return res.sendStatus(403); - } - - completedChallenge = { - id: body.challengeInfo.challengeId, - name: body.challengeInfo.challengeName || '', - completedDate: Date.now(), - - challengeType: +body.challengeInfo.challengeType === 4 ? 4 : 3, - - solution: body.challengeInfo.publicURL, - githubLink: body.challengeInfo.githubURL - }; - } else { - completedChallenge = _.pick( - body, - [ 'id', 'name', 'solution', 'githubLink', 'challengeType' ] - ); - completedChallenge.challengeType = +completedChallenge.challengeType; - completedChallenge.completedDate = Date.now(); - } + const completedChallenge = _.pick( + body, + [ 'id', 'name', 'solution', 'githubLink', 'challengeType' ] + ); + completedChallenge.challengeType = +completedChallenge.challengeType; + completedChallenge.completedDate = Date.now(); if ( !completedChallenge.solution || @@ -603,18 +621,30 @@ module.exports = function(app) { ) { req.flash('errors', { msg: 'You haven\'t supplied the necessary URLs for us to inspect ' + - 'your work.' + 'your work.' }); return res.sendStatus(403); } const { + alreadyCompleted, updateData } = buildUserUpdate(req.user, completedChallenge.id, completedChallenge); - return user.updateTo$(updateData) - .doOnNext(() => res.status(200).send(true)) + return user.update$(updateData) + .doOnNext(({ count }) => log('%s documents updated', count)) + .doOnNext(() => { + if (type === 'json') { + return res.send({ + alreadyCompleted, + points: alreadyCompleted ? + user.progressTimestamps.length : + user.progressTimestamps.length + 1 + }); + } + res.status(200).send(true); + }) .subscribe(() => {}, next); } diff --git a/server/middlewares/validator.js b/server/middlewares/validator.js index b40765bc89..7405525bef 100644 --- a/server/middlewares/validator.js +++ b/server/middlewares/validator.js @@ -1,9 +1,17 @@ import validator from 'express-validator'; -export default validator.bind(validator, { - customValidators: { - matchRegex: function matchRegex(param, regex) { - return regex.test(param); +export default function() { + return validator({ + customValidators: { + matchRegex(param, regex) { + return regex.test(param); + }, + isString(value) { + return typeof value === 'string'; + }, + isNumber(value) { + return typeof value === 'number'; + } } - } -}); + }); +}