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'