freeCodeCamp/server/boot/challenge.js

622 lines
17 KiB
JavaScript
Raw Normal View History

2015-08-10 05:14:31 +00:00
import _ from 'lodash';
2015-08-29 08:48:01 +00:00
import dedent from 'dedent';
2015-08-10 05:14:31 +00:00
import moment from 'moment';
import { Observable, Scheduler } from 'rx';
2015-08-10 05:14:31 +00:00
import debugFactory from 'debug';
import accepts from 'accepts';
import {
dasherize,
unDasherize,
getMDNLinks,
randomVerb,
randomPhrase,
randomCompliment
} from '../utils';
2015-08-10 05:14:31 +00:00
import { saveUser, observeMethod } from '../utils/rx';
2015-08-10 05:14:31 +00:00
import {
ifNoUserSend
} from '../utils/middleware';
import getFromDisk$ from '../utils/getFromDisk$';
const isDev = process.env.NODE_ENV !== 'production';
const isBeta = !!process.env.BETA;
2015-08-10 05:14:31 +00:00
const debug = debugFactory('freecc:challenges');
2016-01-02 05:10:08 +00:00
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'
};
2015-06-20 20:35:26 +00:00
function isChallengeCompleted(user, challengeId) {
if (!user) {
return false;
}
return user.completedChallenges.some(challenge =>
challenge.id === challengeId );
}
/*
2015-08-10 05:14:31 +00:00
function numberWithCommas(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
*/
2015-08-10 05:14:31 +00:00
function updateUserProgress(user, challengeId, completedChallenge) {
let { completedChallenges } = user;
const indexOfChallenge = _.findIndex(completedChallenges, {
id: challengeId
});
const alreadyCompleted = indexOfChallenge !== -1;
if (!alreadyCompleted) {
user.progressTimestamps.push({
timestamp: Date.now(),
completedChallenge: challengeId
});
user.completedChallenges.push(completedChallenge);
return user;
}
const oldCompletedChallenge = completedChallenges[indexOfChallenge];
user.completedChallenges[indexOfChallenge] =
Object.assign(
{},
completedChallenge,
{
completedDate: oldCompletedChallenge.completedDate,
lastUpdated: completedChallenge.completedDate
}
);
return { user, alreadyCompleted };
}
2015-12-05 06:13:14 +00:00
// 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;
2015-12-05 06:13:14 +00:00
}
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');
debug('looking for %s', testChallengeName);
return challenge$
2016-01-15 09:44:18 +00:00
.map(challenge => challenge.toJSON())
.filter((challenge) => {
return testChallengeName.test(challenge.name) &&
shouldNotFilterComingSoon(challenge);
})
.last({ defaultValue: null })
.flatMap(challenge => {
if (challenge && isDev) {
return getFromDisk$(challenge);
}
return Observable.just(challenge);
})
.flatMap(challenge => {
// Handle not found
if (!challenge) {
debug('did not find challenge for ' + origChallengeName);
return Observable.just({
type: 'redirect',
redirectUrl: '/map',
message: dedent`
2016-01-15 14:08:54 +00:00
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
}
});
});
}
2016-01-13 06:26:19 +00:00
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) {
return challenge$
// mark challenge completed
.map(challengeModel => {
const challenge = challengeModel.toJSON();
if (completedChallenges.indexOf(challenge.id) !== -1) {
challenge.completed = true;
}
challenge.markNew = shouldShowNew(challenge);
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');
2016-01-13 08:27:08 +00:00
const isRequired = _.every(blockArray, 'isRequired');
return {
isBeta,
isComingSoon,
2016-01-13 22:59:51 +00:00
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 || '???'
};
})
// filter out hikes
.filter(({ superBlock }) => {
return !(/hikes/i).test(superBlock);
})
// turn stream of blocks into a stream of an array
.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();
}
2016-01-14 23:15:44 +00:00
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 }) => !(/hikes/gi).test(superBlock))
.first();
}
return challenge$
.map(challenge => challenge.toJSON())
// filter out challenges coming soon
.filter(shouldNotFilterComingSoon)
// filter out hikes
.filter(({ superBlock }) => !(/hikes/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();
}
2015-06-03 02:02:54 +00:00
module.exports = function(app) {
2015-08-10 05:14:31 +00:00
const router = app.loopback.Router();
const challengesQuery = {
order: [
2015-12-07 05:44:34 +00:00
'superOrder ASC',
'order ASC',
'suborder ASC'
]
};
// challenge model
2015-08-10 05:14:31 +00:00
const Challenge = app.models.Challenge;
// challenge find query stream
2015-08-10 05:14:31 +00:00
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
2015-12-11 23:45:48 +00:00
const blocks$ = challenge$
2015-08-16 21:19:30 +00:00
.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
2015-12-05 06:13:14 +00:00
.flatMap(blocks$ => blocks$.toArray())
// turn array into stream of object
2015-12-11 23:45:48 +00:00
.map(blocksArray => ({
name: blocksArray[0].block,
dashedName: dasherize(blocksArray[0].block),
challenges: blocksArray,
superBlock: blocksArray[0].superBlock,
order: blocksArray[0].order
2015-08-13 03:39:40 +00:00
}))
// filter out hikes
.filter(({ superBlock }) => {
return !(/hikes/gi).test(superBlock);
2015-08-13 03:39:40 +00:00
})
.shareReplay();
2015-08-10 05:14:31 +00:00
2016-01-14 23:15:44 +00:00
const firstChallenge$ = challenge$
.first()
.map(challenge => challenge.toJSON())
.shareReplay();
const lastChallenge$ = challenge$
.last()
.map(challenge => challenge.toJSON())
.shareReplay();
2015-08-10 05:14:31 +00:00
const send200toNonUser = ifNoUserSend(true);
router.post(
'/completed-challenge/',
send200toNonUser,
completedChallenge
);
router.post(
'/completed-zipline-or-basejump',
send200toNonUser,
completedZiplineOrBasejump
);
2015-06-03 02:02:54 +00:00
2016-01-13 22:59:51 +00:00
router.get('/map', showMap.bind(null, false));
router.get('/map-aside', showMap.bind(null, true));
2016-01-14 23:15:44 +00:00
router.get(
'/challenges/current-challenge',
redirectToCurrentChallenge
);
2015-06-20 18:43:12 +00:00
router.get(
'/challenges/next-challenge',
2016-01-14 23:15:44 +00:00
redirectToNextChallenge
2015-06-20 18:43:12 +00:00
);
router.get('/challenges/:challengeName', showChallenge);
2015-06-20 18:43:12 +00:00
app.use(router);
2016-01-14 23:15:44 +00:00
function redirectToCurrentChallenge(req, res, next) {
2016-01-15 09:44:18 +00:00
let challengeId = req.query.id || req.cookies.currentChallengeId;
// prevent serialized null/undefined from breaking things
if (challengeId === 'undefined' || challengeId === 'null') {
challengeId = null;
}
2016-01-14 23:15:44 +00:00
getChallengeById$(challenge$, challengeId)
.doOnNext(({ dashedName })=> {
if (!dashedName) {
debug('no challenge found for %s', challengeId);
req.flash('info', {
msg: `We coudn't find a challenge with the id ${challengeId}`
});
2016-01-14 23:15:44 +00:00
res.redirect('/map');
}
2016-01-14 23:15:44 +00:00
res.redirect('/challenges/' + dashedName);
})
2016-01-14 23:15:44 +00:00
.subscribe(() => {}, next);
}
function redirectToNextChallenge(req, res, next) {
2016-01-15 09:44:18 +00:00
let challengeId = req.query.id || req.cookies.currentChallengeId;
if (challengeId === 'undefined' || challengeId === 'null') {
challengeId = null;
}
2016-01-14 23:15:44 +00:00
Observable.combineLatest(
firstChallenge$,
lastChallenge$
2016-01-14 23:15:44 +00:00
)
.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');
});
2015-06-20 18:43:12 +00:00
}
2016-01-14 23:15:44 +00:00
return getNextChallenge$(challenge$, blocks$, challengeId)
.doOnNext(({ dashedName } = {}) => {
if (!dashedName) {
debug('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;
2016-01-15 14:08:54 +00:00
const challengeName = req.params.challengeName.replace(challengesRegex, '');
2016-01-13 06:26:19 +00:00
2016-01-15 14:08:54 +00:00
getRenderData$(req.user, challenge$, challengeName, solution)
.subscribe(
({ type, redirectUrl, message, data }) => {
if (message) {
req.flash('info', {
msg: message
});
}
if (type === 'redirect') {
debug('redirecting to %s', redirectUrl);
return res.redirect(redirectUrl);
}
var view = challengeView[data.challengeType];
2016-01-15 09:44:18 +00:00
if (data.id) {
res.cookie('currentChallengeId', data.id);
}
res.render(view, data);
},
next,
function() {}
);
2015-06-03 02:02:54 +00:00
}
function completedChallenge(req, res, next) {
const type = accepts(req).type('html', 'json', 'text');
2015-06-03 02:02:54 +00:00
const completedDate = Date.now();
const {
id,
name,
challengeType,
solution,
timezone
} = req.body;
2015-06-03 02:02:54 +00:00
const { alreadyCompleted } = updateUserProgress(
req.user,
id,
{
id,
challengeType,
solution,
name,
completedDate,
verified: true
}
);
2016-02-01 22:16:27 +00:00
if (timezone && (!req.user.timezone || req.user.timezone !== timezone)) {
req.user.timezone = timezone;
}
let user = req.user;
saveUser(req.user)
.subscribe(
function(user) {
user = user;
},
next,
function() {
if (type === 'json') {
return res.json({
points: user.progressTimestamps.length,
alreadyCompleted
});
}
res.sendStatus(200);
}
);
}
2015-06-03 02:02:54 +00:00
function completedZiplineOrBasejump(req, res, next) {
const { 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);
}
2015-06-03 02:02:54 +00:00
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();
}
if (
!completedChallenge.solution ||
// only basejumps require github links
(
completedChallenge.challengeType === 4 &&
!completedChallenge.githubLink
)
) {
2015-06-03 02:02:54 +00:00
req.flash('errors', {
msg: 'You haven\'t supplied the necessary URLs for us to inspect ' +
'your work.'
});
return res.sendStatus(403);
}
2015-05-21 07:17:44 +00:00
updateUserProgress(req.user, completedChallenge.id, completedChallenge);
return saveUser(req.user)
.doOnNext(() => res.status(200).send(true))
.subscribe(() => {}, next);
}
function showMap(showAside, { user }, res, next) {
2016-01-13 06:26:19 +00:00
getSuperBlocks$(challenge$, getCompletedChallengeIds(user))
2015-08-10 05:14:31 +00:00
.subscribe(
superBlocks => {
res.render('map/show', {
2015-12-05 06:13:14 +00:00
superBlocks,
title: 'A Map to Learn to Code and Become a Software Engineer',
showAside
2015-08-10 05:14:31 +00:00
});
},
next
);
}
2015-06-03 02:02:54 +00:00
};