import _ from 'lodash'; import dedent from 'dedent'; import moment from 'moment'; import { Observable, Scheduler } from 'rx'; import debug from 'debug'; import accepts from 'accepts'; import { isMongoId } from 'validator'; import { dasherize, unDasherize, getMDNLinks, randomVerb, randomPhrase, randomCompliment } from '../utils'; import { observeMethod } from '../utils/rx'; import { ifNoUserSend } from '../utils/middleware'; import getFromDisk$ from '../utils/getFromDisk$'; import badIdMap from '../utils/bad-id-map'; const isDev = process.env.NODE_ENV !== 'production'; const isBeta = !!process.env.BETA; const log = debug('freecc:challenges'); 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' }; function isChallengeCompleted(user, challengeId) { if (!user) { return false; } return !!user.challengeMap[challengeId]; } /* function numberWithCommas(x) { return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); } */ function buildUserUpdate( user, challengeId, completedChallenge, timezone ) { const updateData = { $set: {} }; let finalChallenge; const { timezone: userTimezone, challengeMap = {} } = user; const oldChallenge = challengeMap[challengeId]; const alreadyCompleted = !!oldChallenge; if (alreadyCompleted) { // add data from old challenge finalChallenge = { ...completedChallenge, completedDate: oldChallenge.completedDate, lastUpdated: completedChallenge.completedDate }; } else { updateData.$push = { progressTimestamps: { timestamp: Date.now(), completedChallenge: challengeId } }; finalChallenge = completedChallenge; } updateData.$set = { [`challengeMap.${challengeId}`]: finalChallenge }; if ( timezone && timezone !== 'UTC' && (!userTimezone || userTimezone === 'UTC') ) { updateData.$set = { ...updateData.$set, timezone: userTimezone }; } log('user update data', updateData); return { alreadyCompleted, updateData }; } // 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; } 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'); log('looking for %s', testChallengeName); return challenge$ .map(challenge => challenge.toJSON()) .filter((challenge) => { return shouldNotFilterComingSoon(challenge) && challenge.type !== 'hike' && testChallengeName.test(challenge.name); }) .last({ defaultValue: null }) .flatMap(challenge => { if (challenge && isDev) { return getFromDisk$(challenge); } return Observable.just(challenge); }) .flatMap(challenge => { // Handle not found if (!challenge) { log('did not find challenge for ' + origChallengeName); return Observable.just({ type: 'redirect', redirectUrl: '/map', message: dedent` 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 } }); }); } // create a stream of an array of all the challenge blocks function getSuperBlocks$(challenge$, challengeMap) { return challenge$ // mark challenge completed .map(challengeModel => { const challenge = challengeModel.toJSON(); challenge.completed = !!challengeMap[challenge.id]; challenge.markNew = shouldShowNew(challenge); if (challenge.type === 'hike') { challenge.url = '/videos/' + challenge.dashedName; } else { challenge.url = '/challenges/' + challenge.dashedName; } 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'); const isRequired = _.every(blockArray, 'isRequired'); return { isBeta, isComingSoon, 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 || '???' }; }) .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(); } 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 }) => !(/^videos/gi).test(superBlock)) .first(); } return challenge$ .map(challenge => challenge.toJSON()) // filter out challenges coming soon .filter(shouldNotFilterComingSoon) // filter out hikes .filter(({ superBlock }) => !(/^videos/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(); } module.exports = function(app) { const router = app.loopback.Router(); const challengesQuery = { order: [ 'superOrder ASC', '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) .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()) .filter(shouldNotFilterComingSoon) // group challenges by block | returns a stream of observables .groupBy(challenge => challenge.block) // turn block group stream into an array .flatMap(blocks$ => blocks$.toArray()) // turn array into stream of object .map(blocksArray => ({ name: blocksArray[0].block, dashedName: dasherize(blocksArray[0].block), challenges: blocksArray, superBlock: blocksArray[0].superBlock, order: blocksArray[0].order })) // filter out hikes .filter(({ superBlock }) => { return !(/^videos/gi).test(superBlock); }) .shareReplay(); const firstChallenge$ = challenge$ .first() .map(challenge => challenge.toJSON()) .shareReplay(); const lastChallenge$ = challenge$ .last() .map(challenge => challenge.toJSON()) .shareReplay(); const send200toNonUser = ifNoUserSend(true); router.post( '/completed-challenge/', send200toNonUser, completedChallenge ); router.post( '/completed-zipline-or-basejump', send200toNonUser, completedZiplineOrBasejump ); router.get('/map', showMap.bind(null, false)); router.get('/map-aside', showMap.bind(null, true)); router.get( '/challenges/current-challenge', redirectToCurrentChallenge ); router.get( '/challenges/next-challenge', redirectToNextChallenge ); router.get('/challenges/:challengeName', showChallenge); app.use(router); function redirectToCurrentChallenge(req, res, next) { let challengeId = req.query.id || req.cookies.currentChallengeId; // prevent serialized null/undefined from breaking things if (badIdMap[challengeId]) { challengeId = badIdMap[challengeId]; } if (!isMongoId(challengeId)) { challengeId = null; } getChallengeById$(challenge$, challengeId) .doOnNext(({ dashedName })=> { if (!dashedName) { log('no challenge found for %s', challengeId); req.flash('info', { msg: `We coudn't find a challenge with the id ${challengeId}` }); res.redirect('/map'); } res.redirect('/challenges/' + dashedName); }) .subscribe(() => {}, next); } function redirectToNextChallenge(req, res, next) { let challengeId = req.query.id || req.cookies.currentChallengeId; if (badIdMap[challengeId]) { challengeId = badIdMap[challengeId]; } if (!isMongoId(challengeId)) { challengeId = null; } Observable.combineLatest( firstChallenge$, lastChallenge$ ) .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 Half Way Club and start getting ready for our nonprofit projects. `.split('\n').join(' ') }); return res.redirect('/map'); }); } return getNextChallenge$(challenge$, blocks$, challengeId); }) .doOnNext(({ dashedName } = {}) => { if (!dashedName) { log('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; const challengeName = req.params.challengeName.replace(challengesRegex, ''); getRenderData$(req.user, challenge$, challengeName, solution) .subscribe( ({ type, redirectUrl, message, data }) => { if (message) { req.flash('info', { msg: message }); } if (type === 'redirect') { log('redirecting to %s', redirectUrl); return res.redirect(redirectUrl); } var view = challengeView[data.challengeType]; if (data.id) { res.cookie('currentChallengeId', data.id); } res.render(view, data); }, next, function() {} ); } function completedChallenge(req, res, next) { req.checkBody('id', 'id must be a ObjectId').isMongoId(); req.checkBody('name', 'name must be at least 3 characters') .isString() .isLength({ min: 3 }); req.checkBody('challengeType', 'challengeType must be an integer') .isNumber() .isInt(); const type = accepts(req).type('html', 'json', 'text'); const errors = req.validationErrors(true); if (errors) { if (type === 'json') { return res.status(403).send({ errors }); } log('errors', errors); return res.sendStatus(403); } const completedDate = Date.now(); const { id, name, challengeType, solution, timezone } = req.body; const { alreadyCompleted, updateData } = buildUserUpdate( req.user, id, { id, challengeType, solution, name, completedDate }, timezone ); const user = req.user; const points = alreadyCompleted ? user.progressTimestamps.length : user.progressTimestamps.length + 1; return user.update$(updateData) .doOnNext(({ count }) => log('%s documents updated', count)) .subscribe( () => {}, next, function() { if (type === 'json') { return res.json({ points, alreadyCompleted }); } res.sendStatus(200); } ); } function completedZiplineOrBasejump(req, res, next) { const type = accepts(req).type('html', 'json', 'text'); req.checkBody('id', 'id must be an ObjectId').isMongoId(); req.checkBody('name', 'Name must be at least 3 characters') .isString() .isLength({ min: 3 }); req.checkBody('challengeType', 'must be a number') .isNumber() .isInt(); req.checkBody('solution', 'solution must be a url').isURL(); const errors = req.validationErrors(true); if (errors) { if (type === 'json') { return res.status(403).send({ errors }); } log('errors', errors); return res.sendStatus(403); } const { user, body = {} } = req; const 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 ) ) { req.flash('errors', { msg: 'You haven\'t supplied the necessary URLs for us to inspect ' + 'your work.' }); return res.sendStatus(403); } const { alreadyCompleted, updateData } = buildUserUpdate(req.user, completedChallenge.id, completedChallenge); return user.update$(updateData) .doOnNext(({ count }) => log('%s documents updated', count)) .doOnNext(() => { if (type === 'json') { return res.send({ alreadyCompleted, points: alreadyCompleted ? user.progressTimestamps.length : user.progressTimestamps.length + 1 }); } res.status(200).send(true); }) .subscribe(() => {}, next); } function showMap(showAside, { user = {} }, res, next) { const { challengeMap = {} } = user; return getSuperBlocks$(challenge$, challengeMap) .subscribe( superBlocks => { res.render('map/show', { superBlocks, title: 'A Map to Learn to Code and Become a Software Engineer', showAside }); }, next ); } };