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
580 lines
16 KiB
JavaScript
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
|
|
);
|
|
}
|
|
};
|