Permalink
Cannot retrieve contributors at this time
Fetching contributors…

import _ from 'lodash'; | |
import dedent from 'dedent'; | |
import moment from 'moment'; | |
import { Observable, Scheduler } from 'rx'; | |
import debug from 'debug'; | |
import accepts from 'accepts'; | |
import { isMongoId } from 'validator'; | |
import { | |
dasherize, | |
unDasherize, | |
getMDNLinks, | |
randomVerb, | |
randomPhrase, | |
randomCompliment | |
} from '../utils'; | |
import { observeMethod } from '../utils/rx'; | |
import { | |
ifNoUserSend | |
} from '../utils/middleware'; | |
import getFromDisk$ from '../utils/getFromDisk$'; | |
import badIdMap from '../utils/bad-id-map'; | |
const isDev = process.env.NODE_ENV !== 'production'; | |
const isBeta = !!process.env.BETA; | |
const log = debug('fcc:challenges'); | |
const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i; | |
const challengeView = { | |
0: 'challenges/showHTML', | |
1: 'challenges/showJS', | |
2: 'challenges/showVideo', | |
3: 'challenges/showZiplineOrBasejump', | |
4: 'challenges/showZiplineOrBasejump', | |
5: 'challenges/showBonfire', | |
7: 'challenges/showStep' | |
}; | |
function isChallengeCompleted(user, challengeId) { | |
if (!user) { | |
return false; | |
} | |
return !!user.challengeMap[challengeId]; | |
} | |
/* | |
function numberWithCommas(x) { | |
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); | |
} | |
*/ | |
function buildUserUpdate( | |
user, | |
challengeId, | |
completedChallenge, | |
timezone | |
) { | |
const updateData = { $set: {} }; | |
let finalChallenge; | |
const { timezone: userTimezone, challengeMap = {} } = user; | |
const oldChallenge = challengeMap[challengeId]; | |
const alreadyCompleted = !!oldChallenge; | |
if (alreadyCompleted) { | |
// add data from old challenge | |
finalChallenge = { | |
...completedChallenge, | |
completedDate: oldChallenge.completedDate, | |
lastUpdated: completedChallenge.completedDate | |
}; | |
} else { | |
updateData.$push = { | |
progressTimestamps: { | |
timestamp: Date.now(), | |
completedChallenge: challengeId | |
} | |
}; | |
finalChallenge = completedChallenge; | |
} | |
updateData.$set = { | |
[`challengeMap.${challengeId}`]: finalChallenge | |
}; | |
if ( | |
timezone && | |
timezone !== 'UTC' && | |
(!userTimezone || userTimezone === 'UTC') | |
) { | |
updateData.$set = { | |
...updateData.$set, | |
timezone: userTimezone | |
}; | |
} | |
log('user update data', updateData); | |
return { alreadyCompleted, updateData }; | |
} | |
// small helper function to determine whether to mark something as new | |
const dateFormat = 'MMM MMMM DD, YYYY'; | |
function shouldShowNew(element, block) { | |
if (element) { | |
return typeof element.releasedOn !== 'undefined' && | |
moment(element.releasedOn, dateFormat).diff(moment(), 'days') >= -60; | |
} | |
if (block) { | |
const newCount = block.reduce((sum, { markNew }) => { | |
if (markNew) { | |
return sum + 1; | |
} | |
return sum; | |
}, 0); | |
return newCount / block.length * 100 === 100; | |
} | |
return null; | |
} | |
// meant to be used with a filter method | |
// on an array or observable stream | |
// true if challenge should be passed through | |
// false if should filter challenge out of array or stream | |
function shouldNotFilterComingSoon({ isComingSoon, isBeta: challengeIsBeta }) { | |
return isDev || | |
!isComingSoon || | |
(isBeta && challengeIsBeta); | |
} | |
function getRenderData$(user, challenge$, origChallengeName, solution) { | |
const challengeName = unDasherize(origChallengeName) | |
.replace(challengesRegex, ''); | |
const testChallengeName = new RegExp(challengeName, 'i'); | |
log('looking for %s', testChallengeName); | |
return challenge$ | |
.map(challenge => challenge.toJSON()) | |
.filter((challenge) => { | |
return shouldNotFilterComingSoon(challenge) && | |
challenge.type !== 'hike' && | |
testChallengeName.test(challenge.name); | |
}) | |
.last({ defaultValue: null }) | |
.flatMap(challenge => { | |
if (challenge && isDev) { | |
return getFromDisk$(challenge); | |
} | |
return Observable.just(challenge); | |
}) | |
.flatMap(challenge => { | |
// Handle not found | |
if (!challenge) { | |
log('did not find challenge for ' + origChallengeName); | |
return Observable.just({ | |
type: 'redirect', | |
redirectUrl: '/map', | |
message: dedent` | |
We couldn't find a challenge with the name ${origChallengeName}. | |
Please double check the name. | |
` | |
}); | |
} | |
if (dasherize(challenge.name) !== origChallengeName) { | |
let redirectUrl = `/challenges/${dasherize(challenge.name)}`; | |
if (solution) { | |
redirectUrl += `?solution=${encodeURIComponent(solution)}`; | |
} | |
return Observable.just({ | |
type: 'redirect', | |
redirectUrl | |
}); | |
} | |
// save user does nothing if user does not exist | |
return Observable.just({ | |
data: { | |
...challenge, | |
// identifies if a challenge is completed | |
isCompleted: isChallengeCompleted(user, challenge.id), | |
// video challenges | |
video: challenge.challengeSeed[0], | |
// bonfires specific | |
bonfires: challenge, | |
MDNkeys: challenge.MDNlinks, | |
MDNlinks: getMDNLinks(challenge.MDNlinks), | |
// htmls specific | |
verb: randomVerb(), | |
phrase: randomPhrase(), | |
compliment: randomCompliment(), | |
// Google Analytics | |
gaName: challenge.title + '~' + challenge.checksum | |
} | |
}); | |
}); | |
} | |
// create a stream of an array of all the challenge blocks | |
function getSuperBlocks$(challenge$, challengeMap) { | |
return challenge$ | |
// mark challenge completed | |
.map(challengeModel => { | |
const challenge = challengeModel.toJSON(); | |
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 | |
.groupBy(challenge => challenge.block) | |
// turn block group stream into an array | |
.flatMap(block$ => block$.toArray()) | |
.map(blockArray => { | |
const completedCount = blockArray.reduce((sum, { completed }) => { | |
if (completed) { | |
return sum + 1; | |
} | |
return sum; | |
}, 0); | |
const isBeta = _.every(blockArray, 'isBeta'); | |
const isComingSoon = _.every(blockArray, 'isComingSoon'); | |
const isRequired = _.every(blockArray, 'isRequired'); | |
return { | |
isBeta, | |
isComingSoon, | |
isRequired, | |
name: blockArray[0].block, | |
superBlock: blockArray[0].superBlock, | |
dashedName: dasherize(blockArray[0].block), | |
markNew: shouldShowNew(null, blockArray), | |
challenges: blockArray, | |
completed: completedCount / blockArray.length * 100, | |
time: blockArray[0] && blockArray[0].time || '???' | |
}; | |
}) | |
.toArray() | |
.flatMap(blocks => Observable.from(blocks, null, null, Scheduler.default)) | |
.groupBy(block => block.superBlock) | |
.flatMap(blocks$ => blocks$.toArray()) | |
.map(superBlockArray => ({ | |
name: superBlockArray[0].superBlock, | |
blocks: superBlockArray | |
})) | |
.toArray(); | |
} | |
function getChallengeById$(challenge$, challengeId) { | |
// return first challenge if no id is given | |
if (!challengeId) { | |
return challenge$ | |
.map(challenge => challenge.toJSON()) | |
.filter(shouldNotFilterComingSoon) | |
// filter out hikes | |
.filter(({ superBlock }) => !(/^videos/gi).test(superBlock)) | |
.first(); | |
} | |
return challenge$ | |
.map(challenge => challenge.toJSON()) | |
// filter out challenges coming soon | |
.filter(shouldNotFilterComingSoon) | |
// filter out hikes | |
.filter(({ superBlock }) => !(/^videos/gi).test(superBlock)) | |
.filter(({ id }) => id === challengeId); | |
} | |
function getNextChallenge$(challenge$, blocks$, challengeId) { | |
return getChallengeById$(challenge$, challengeId) | |
// now lets find the block it belongs to | |
.flatMap(challenge => { | |
// find the index of the block this challenge resides in | |
const blockIndex$ = blocks$ | |
.findIndex(({ name }) => name === challenge.block); | |
return blockIndex$ | |
.flatMap(blockIndex => { | |
// could not find block? | |
if (blockIndex === -1) { | |
return Observable.throw( | |
'could not find challenge block for ' + challenge.block | |
); | |
} | |
const firstChallengeOfNextBlock$ = blocks$ | |
.elementAt(blockIndex + 1, {}) | |
.map(({ challenges = [] }) => challenges[0]); | |
return blocks$ | |
.filter(shouldNotFilterComingSoon) | |
.elementAt(blockIndex) | |
.flatMap(block => { | |
// find where our challenge lies in the block | |
const challengeIndex$ = Observable.from( | |
block.challenges, | |
null, | |
null, | |
Scheduler.default | |
) | |
.findIndex(({ id }) => id === challengeId); | |
// grab next challenge in this block | |
return challengeIndex$ | |
.map(index => { | |
return block.challenges[index + 1]; | |
}) | |
.flatMap(nextChallenge => { | |
if (!nextChallenge) { | |
return firstChallengeOfNextBlock$; | |
} | |
return Observable.just(nextChallenge); | |
}); | |
}); | |
}); | |
}) | |
.first(); | |
} | |
module.exports = function(app) { | |
const router = app.loopback.Router(); | |
const challengesQuery = { | |
order: [ | |
'superOrder ASC', | |
'order ASC', | |
'suborder ASC' | |
] | |
}; | |
// challenge model | |
const Challenge = app.models.Challenge; | |
// challenge find query stream | |
const findChallenge$ = observeMethod(Challenge, 'find'); | |
// create a stream of all the challenges | |
const challenge$ = findChallenge$(challengesQuery) | |
.flatMap(challenges => Observable.from( | |
challenges, | |
null, | |
null, | |
Scheduler.default | |
)) | |
// filter out all challenges that have isBeta flag set | |
// except in development or beta site | |
.filter(challenge => isDev || isBeta || !challenge.isBeta) | |
.shareReplay(); | |
// create a stream of challenge blocks | |
const blocks$ = challenge$ | |
.map(challenge => challenge.toJSON()) | |
.filter(shouldNotFilterComingSoon) | |
// group challenges by block | returns a stream of observables | |
.groupBy(challenge => challenge.block) | |
// turn block group stream into an array | |
.flatMap(blocks$ => blocks$.toArray()) | |
// turn array into stream of object | |
.map(blocksArray => ({ | |
name: blocksArray[0].block, | |
dashedName: dasherize(blocksArray[0].block), | |
challenges: blocksArray, | |
superBlock: blocksArray[0].superBlock, | |
order: blocksArray[0].order | |
})) | |
// filter out hikes | |
.filter(({ superBlock }) => { | |
return !(/^videos/gi).test(superBlock); | |
}) | |
.shareReplay(); | |
const firstChallenge$ = challenge$ | |
.first() | |
.map(challenge => challenge.toJSON()) | |
.shareReplay(); | |
const lastChallenge$ = challenge$ | |
.last() | |
.map(challenge => challenge.toJSON()) | |
.shareReplay(); | |
const send200toNonUser = ifNoUserSend(true); | |
router.post( | |
'/completed-challenge/', | |
send200toNonUser, | |
completedChallenge | |
); | |
router.post( | |
'/completed-zipline-or-basejump', | |
send200toNonUser, | |
completedZiplineOrBasejump | |
); | |
router.get('/map', showMap.bind(null, false)); | |
router.get('/map-aside', showMap.bind(null, true)); | |
router.get( | |
'/challenges/current-challenge', | |
redirectToCurrentChallenge | |
); | |
router.get( | |
'/challenges/next-challenge', | |
redirectToNextChallenge | |
); | |
router.get('/challenges/:challengeName', showChallenge); | |
app.use(router); | |
function redirectToCurrentChallenge(req, res, next) { | |
let challengeId = req.query.id || req.cookies.currentChallengeId; | |
// prevent serialized null/undefined from breaking things | |
if (badIdMap[challengeId]) { | |
challengeId = badIdMap[challengeId]; | |
} | |
if (!isMongoId(challengeId)) { | |
challengeId = null; | |
} | |
getChallengeById$(challenge$, challengeId) | |
.doOnNext(({ dashedName })=> { | |
if (!dashedName) { | |
log('no challenge found for %s', challengeId); | |
req.flash('info', { | |
msg: `We coudn't find a challenge with the id ${challengeId}` | |
}); | |
res.redirect('/map'); | |
} | |
res.redirect('/challenges/' + dashedName); | |
}) | |
.subscribe(() => {}, next); | |
} | |
function redirectToNextChallenge(req, res, next) { | |
let challengeId = req.query.id || req.cookies.currentChallengeId; | |
if (badIdMap[challengeId]) { | |
challengeId = badIdMap[challengeId]; | |
} | |
if (!isMongoId(challengeId)) { | |
challengeId = null; | |
} | |
Observable.combineLatest( | |
firstChallenge$, | |
lastChallenge$ | |
) | |
.flatMap(([firstChallenge, { id: lastChallengeId } ]) => { | |
// no id supplied, load first challenge | |
if (!challengeId) { | |
return Observable.just(firstChallenge); | |
} | |
// camper just completed last challenge | |
if (challengeId === lastChallengeId) { | |
return Observable.just() | |
.doOnCompleted(() => { | |
req.flash('info', { | |
msg: dedent` | |
Once you have completed all of our challenges, you should | |
join our <a href="https://gitter.im/freecodecamp/HalfWayClub" | |
target="_blank">Half Way Club</a> and start getting | |
ready for our nonprofit projects. | |
`.split('\n').join(' ') | |
}); | |
return res.redirect('/map'); | |
}); | |
} | |
return getNextChallenge$(challenge$, blocks$, challengeId); | |
}) | |
.doOnNext(({ dashedName } = {}) => { | |
if (!dashedName) { | |
log('no challenge found for %s', challengeId); | |
res.redirect('/map'); | |
} | |
res.redirect('/challenges/' + dashedName); | |
}) | |
.subscribe(() => {}, next); | |
} | |
function showChallenge(req, res, next) { | |
const solution = req.query.solution; | |
const challengeName = req.params.challengeName.replace(challengesRegex, ''); | |
getRenderData$(req.user, challenge$, challengeName, solution) | |
.subscribe( | |
({ type, redirectUrl, message, data }) => { | |
if (message) { | |
req.flash('info', { | |
msg: message | |
}); | |
} | |
if (type === 'redirect') { | |
log('redirecting to %s', redirectUrl); | |
return res.redirect(redirectUrl); | |
} | |
var view = challengeView[data.challengeType]; | |
if (data.id) { | |
res.cookie('currentChallengeId', data.id); | |
} | |
return res.render(view, data); | |
}, | |
next, | |
function() {} | |
); | |
} | |
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, | |
name, | |
challengeType, | |
solution, | |
timezone | |
} = req.body; | |
const { alreadyCompleted, updateData } = buildUserUpdate( | |
req.user, | |
id, | |
{ | |
id, | |
challengeType, | |
solution, | |
name, | |
completedDate | |
}, | |
timezone | |
); | |
const user = req.user; | |
const points = alreadyCompleted ? | |
user.progressTimestamps.length : | |
user.progressTimestamps.length + 1; | |
return user.update$(updateData) | |
.doOnNext(({ count }) => log('%s documents updated', count)) | |
.subscribe( | |
() => {}, | |
next, | |
function() { | |
if (type === 'json') { | |
return res.json({ | |
points, | |
alreadyCompleted | |
}); | |
} | |
return res.sendStatus(200); | |
} | |
); | |
} | |
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; | |
const completedChallenge = _.pick( | |
body, | |
[ 'id', 'name', 'solution', 'githubLink', 'challengeType' ] | |
); | |
completedChallenge.challengeType = +completedChallenge.challengeType; | |
completedChallenge.completedDate = Date.now(); | |
if ( | |
!completedChallenge.solution || | |
// only basejumps require github links | |
( | |
completedChallenge.challengeType === 4 && | |
!completedChallenge.githubLink | |
) | |
) { | |
req.flash('errors', { | |
msg: 'You haven\'t supplied the necessary URLs for us to inspect ' + | |
'your work.' | |
}); | |
return res.sendStatus(403); | |
} | |
const { | |
alreadyCompleted, | |
updateData | |
} = buildUserUpdate(req.user, completedChallenge.id, completedChallenge); | |
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 | |
}); | |
} | |
return res.status(200).send(true); | |
}) | |
.subscribe(() => {}, next); | |
} | |
function showMap(showAside, { user = {} }, res, next) { | |
const { challengeMap = {} } = user; | |
return getSuperBlocks$(challenge$, challengeMap) | |
.subscribe( | |
superBlocks => { | |
res.render('map/show', { | |
superBlocks, | |
title: 'A Map to Learn to Code and Become a Software Engineer', | |
showAside | |
}); | |
}, | |
next | |
); | |
} | |
}; |