freeCodeCamp/server/boot/challenge.js
Berkeley Martinez 44fd85dff8 Add ability to filter beta challenges from production
This PR adds the ability to test out challenges in beta
without fear that they will leak to production servers.

In development all challenges will display.

In production all challenges marked 'isBeta' will be filtered out
except in the case the environmental variable `BETA` is set ture,
in which case all challenges will display.
2015-10-06 22:37:08 -07:00

590 lines
17 KiB
JavaScript

import _ from 'lodash';
import dedent from 'dedent';
import moment from 'moment';
import { Observable, Scheduler } from 'rx';
import assign from 'object.assign';
import debugFactory from 'debug';
import utils from '../utils';
import {
saveUser,
observeMethod,
observeQuery
} from '../utils/rx';
import {
ifNoUserSend
} from '../utils/middleware';
const isDev = process.env.NODE_ENV !== 'production';
const isBeta = !!process.env.BETA;
const debug = debugFactory('freecc:challenges');
const challengesRegex = /^(bonfire|waypoint|zipline|basejump)/i;
const firstChallenge = 'waypoint-say-hello-to-html-elements';
const challengeView = {
0: 'coursewares/showHTML',
1: 'coursewares/showJS',
2: 'coursewares/showVideo',
3: 'coursewares/showZiplineOrBasejump',
4: 'coursewares/showZiplineOrBasejump',
5: 'coursewares/showBonfire',
7: 'coursewares/showStep'
};
const dasherize = utils.dasherize;
const unDasherize = utils.unDasherize;
const getMDNLinks = utils.getMDNLinks;
function makeChallengesUnique(challengeArr) {
// clone and reverse challenges
// then filter by unique id's
// then reverse again
return _.uniq(challengeArr.slice().reverse(), 'id').reverse();
}
function numberWithCommas(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function updateUserProgress(user, challengeId, completedChallenge) {
let { completedChallenges } = user;
// migrate user challenges object to remove
if (!user.isUniqMigrated) {
user.isUniqMigrated = true;
completedChallenges = user.completedChallenges =
makeChallengesUnique(completedChallenges);
}
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;
}
module.exports = function(app) {
const router = app.loopback.Router();
const challengesQuery = {
order: [
'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)
.doOnNext(() => debug('query challenges'))
.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())
// group challenges by block | returns a stream of observables
.groupBy(challenge => challenge.block)
// turn block group stream into an array
.flatMap(block$ => block$.toArray())
// turn array into stream of object
.map(blockArray => ({
name: blockArray[0].block,
dashedName: dasherize(blockArray[0].block),
challenges: blockArray
}))
.filter(({ name })=> {
return name !== 'Hikes';
})
.shareReplay();
const User = app.models.User;
const userCount$ = observeMethod(User, 'count');
const send200toNonUser = ifNoUserSend(true);
router.post(
'/completed-challenge/',
send200toNonUser,
completedChallenge
);
router.post(
'/completed-zipline-or-basejump',
send200toNonUser,
completedZiplineOrBasejump
);
router.post(
'/completed-bonfire',
send200toNonUser,
completedBonfire
);
router.get('/map', challengeMap);
router.get(
'/challenges/next-challenge',
returnNextChallenge
);
router.get('/challenges/:challengeName', returnIndividualChallenge);
app.use(router);
function returnNextChallenge(req, res, next) {
let nextChallengeName = firstChallenge;
const challengeId = req.query.id;
// find challenge
return challenge$
.map(challenge => challenge.toJSON())
.filter(({ block }) => block !== 'Hikes')
.filter(({ id }) => id === 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$
.elementAtOrDefault(blockIndex + 1, {})
.map(({ challenges = [] }) => challenges[0]);
return blocks$
.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);
});
});
});
})
.map(nextChallenge => {
if (!nextChallenge) {
return null;
}
nextChallengeName = nextChallenge.dashedName;
return nextChallengeName;
})
.subscribe(
function() {},
next,
function() {
debug('next challengeName', nextChallengeName);
if (!nextChallengeName || nextChallengeName === firstChallenge) {
req.flash('info', {
msg: dedent`
Once you have completed all of our challenges, you should
join our <a href=\"//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');
}
res.redirect('/challenges/' + nextChallengeName);
}
);
}
function returnIndividualChallenge(req, res, next) {
const origChallengeName = req.params.challengeName;
const solutionCode = req.query.solution;
const unDashedName = unDasherize(origChallengeName);
const challengeName = challengesRegex.test(unDashedName) ?
// remove first word if matches
unDashedName.split(' ').slice(1).join(' ') :
unDashedName;
const testChallengeName = new RegExp(challengeName, 'i');
debug('looking for %s', testChallengeName);
challenge$
.filter((challenge) => {
return testChallengeName.test(challenge.name);
})
.lastOrDefault(null)
.flatMap(challenge => {
// Handle not found
if (!challenge) {
debug('did not find challenge for ' + origChallengeName);
req.flash('errors', {
msg:
'404: We couldn\'t find a challenge with the name `' +
origChallengeName +
'` Please double check the name.'
});
return Observable.just('/challenges');
}
if (dasherize(challenge.name) !== origChallengeName) {
let redirectUrl = `/challenges/${dasherize(challenge.name)}`;
if (solutionCode) {
redirectUrl += `?solution=${encodeURIComponent(solutionCode)}`;
}
return Observable.just(redirectUrl);
}
// save user does nothing if user does not exist
return Observable.just({
title: challenge.name,
dashedName: origChallengeName,
name: challenge.name,
details: challenge.description,
description: challenge.description,
tests: challenge.tests,
challengeSeed: challenge.challengeSeed,
verb: utils.randomVerb(),
phrase: utils.randomPhrase(),
compliment: utils.randomCompliment(),
challengeId: challenge.id,
challengeType: challenge.challengeType,
// video challenges
video: challenge.challengeSeed[0],
// bonfires specific
difficulty: Math.floor(+challenge.difficulty),
bonfires: challenge,
MDNkeys: challenge.MDNlinks,
MDNlinks: getMDNLinks(challenge.MDNlinks),
// htmls specific
environment: utils.whichEnvironment()
});
})
.subscribe(
function(data) {
if (typeof data === 'string') {
debug('redirecting to %s', data);
return res.redirect(data);
}
var view = challengeView[data.challengeType];
res.render(view, data);
},
next,
function() {}
);
}
function completedBonfire(req, res, next) {
debug('compltedBonfire');
var completedWith = req.body.challengeInfo.completedWith || false;
var challengeId = req.body.challengeInfo.challengeId;
var challengeData = {
id: challengeId,
name: req.body.challengeInfo.challengeName || '',
completedDate: Math.round(+new Date()),
solution: req.body.challengeInfo.solution,
challengeType: 5
};
observeQuery(
User,
'findOne',
{ where: { username: ('' + completedWith).toLowerCase() } }
)
.doOnNext(function(pairedWith) {
debug('paired with ', pairedWith);
if (pairedWith) {
updateUserProgress(
pairedWith,
challengeId,
assign({ completedWith: req.user.id }, challengeData)
);
}
})
.withLatestFrom(
Observable.just(req.user),
function(pairedWith, user) {
return {
user: user,
pairedWith: pairedWith
};
}
)
// side effects should always be done in do's and taps
.doOnNext(function(dats) {
updateUserProgress(
dats.user,
challengeId,
dats.pairedWith ?
// paired programmer found and adding to data
assign({ completedWith: dats.pairedWith.id }, challengeData) :
// user said they paired, but pair wasn't found
challengeData
);
})
// iterate users
.flatMap(function(dats) {
debug('flatmap');
return Observable.from([dats.user, dats.pairedWith]);
})
// save user
.flatMap(function(user) {
// save user will do nothing if user is falsey
return saveUser(user);
})
.subscribe(
function(user) {
debug('onNext');
if (user) {
debug('user %s saved', user.username);
}
},
next,
function() {
debug('completed');
return res.status(200).send(true);
}
);
}
function completedChallenge(req, res, next) {
const completedDate = Math.round(+new Date());
const { id, name } = req.body;
const { challengeId, challengeName } = req.body.challengeInfo || {};
updateUserProgress(
req.user,
id || challengeId,
{
id: id || challengeId,
completedDate: completedDate,
name: name || challengeName || '',
solution: null,
githubLink: null,
verified: true
}
);
saveUser(req.user)
.subscribe(
function(user) {
debug(
'user save points %s',
user && user.progressTimestamps && user.progressTimestamps.length
);
},
next,
function() {
res.sendStatus(200);
}
);
}
function completedZiplineOrBasejump(req, res, next) {
var completedWith = req.body.challengeInfo.completedWith || '';
var completedDate = Math.round(+new Date());
var challengeId = req.body.challengeInfo.challengeId;
var solutionLink = req.body.challengeInfo.publicURL;
var githubLink = req.body.challengeInfo.challengeType === '4' ?
req.body.challengeInfo.githubURL :
true;
var challengeType = req.body.challengeInfo.challengeType === '4' ?
4 :
3;
if (!solutionLink || !githubLink) {
req.flash('errors', {
msg: 'You haven\'t supplied the necessary URLs for us to inspect ' +
'your work.'
});
return res.sendStatus(403);
}
var challengeData = {
id: challengeId,
name: req.body.challengeInfo.challengeName || '',
completedDate: completedDate,
solution: solutionLink,
githubLink: githubLink,
challengeType: challengeType,
verified: false
};
observeQuery(
User,
'findOne',
{ where: { username: completedWith.toLowerCase() } }
)
.doOnNext(function(pairedWith) {
if (pairedWith) {
updateUserProgress(
pairedWith,
challengeId,
assign({ completedWith: req.user.id }, challengeData)
);
}
})
.withLatestFrom(Observable.just(req.user), function(pairedWith, user) {
return {
user: user,
pairedWith: pairedWith
};
})
.doOnNext(function({ user, pairedWith }) {
updateUserProgress(
user,
challengeId,
pairedWith ?
assign({ completedWith: pairedWith.id }, challengeData) :
challengeData
);
})
.flatMap(function({ user, pairedWith }) {
return Observable.from([user, pairedWith]);
})
// save users
.flatMap(function(user) {
// save user will do nothing if user is falsey
return saveUser(user);
})
.subscribe(
function(user) {
if (user) {
debug('user %s saved', user.username);
}
},
next,
function() {
return res.status(200).send(true);
}
);
}
function challengeMap({ user = {} }, res, next) {
let lastCompleted;
const daysRunning = moment().diff(new Date('10/15/2014'), 'days');
// if user
// get the id's of all the users completed challenges
const completedChallenges = !user.completedChallenges ?
[] :
_.uniq(user.completedChallenges).map(({ id, _id }) => id || _id);
const camperCount$ = userCount$()
.map(camperCount => numberWithCommas(camperCount));
// create a stream of an array of all the challenge blocks
const blocks$ = challenge$
// mark challenge completed
.map(challengeModel => {
const challenge = challengeModel.toJSON();
if (completedChallenges.indexOf(challenge.id) !== -1) {
challenge.completed = true;
}
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');
return {
isBeta,
name: blockArray[0].block,
dashedName: dasherize(blockArray[0].block),
challenges: blockArray,
completed: completedCount / blockArray.length * 100
};
})
.filter(({ name }) => name !== 'Hikes')
// turn stream of blocks into a stream of an array
.toArray()
.doOnNext((blocks) => {
const lastCompletedBlock = _.findLast(blocks, (block) => {
return block.completed === 100;
});
lastCompleted = lastCompletedBlock && lastCompletedBlock.name || null;
});
Observable.combineLatest(
camperCount$,
blocks$,
(camperCount, blocks) => ({ camperCount, blocks })
)
.subscribe(
({ camperCount, blocks }) => {
res.render('challengeMap/show', {
blocks,
daysRunning,
camperCount,
lastCompleted,
title: "A map of all Free Code Camp's Challenges"
});
},
next
);
}
};