freeCodeCamp/server/boot/challenge.js

676 lines
18 KiB
JavaScript

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('freecc: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;
}
}
// 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);
}
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
});
}
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
});
}
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
);
}
};