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-learn-how-free-code-camp-works'; 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$ .elementAt(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 Half Way Club 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); }) .last({ defaultValue: 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; } if (typeof challenge.releasedOn !== 'undefined' && moment(challenge.releasedOn, 'MMM MMMM DD, YYYY').diff(moment(), 'days') >= -30) { challenge.markNew = 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, time: blockArray[0] && blockArray[0].time || '???' }; }) .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, globalCompletedCount: numberWithCommas( 5612952 + (Math.floor((Date.now() - 1446268581061) / 3000)) ), camperCount, lastCompleted, title: "A Map to Learn to Code and Become a Software Engineer" }); }, next ); } };