Files
freeCodeCamp/server/boot/challenge.js
Berkeley Martinez 85f2c005cd fix shareable challenges solution undefined
During challenges when a user tries to navigate to a challenge
and hits the name redirect, the solution is filled with
undefined starting the user with an empty box.
This PR fixes the issue by ignoring the solution param
if it is empty
2015-10-05 20:18:52 -07:00

580 lines
16 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 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
))
.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 nextBlock$ = blocks$.elementAt(blockIndex + 1);
const firstChallengeOfNextBlock$ = nextBlock$
.map(block => block.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 => {
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);
return {
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
);
}
};