From cc8b608cb9f03618f85b38f11faa6d582b053f23 Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Wed, 1 Jun 2016 15:52:08 -0700 Subject: [PATCH] Moves to next challenges --- client/sagas/completion-saga.js | 31 - client/sagas/index.js | 2 - common/app/App.jsx | 71 ++- common/app/routes/Hikes/redux/answer-saga.js | 2 +- .../app/routes/challenges/components/Show.jsx | 27 +- common/app/routes/challenges/redux/actions.js | 12 + .../challenges/redux/completion-saga.js | 106 ++++ .../challenges/redux/fetch-challenges-saga.js | 7 +- common/app/routes/challenges/redux/index.js | 4 +- common/app/routes/challenges/redux/reducer.js | 7 +- common/app/routes/challenges/redux/types.js | 3 + common/app/routes/challenges/utils.js | 32 ++ common/utils/index.js | 21 +- gulpfile.js | 3 +- server/boot/challenge.js | 531 ++---------------- server/middlewares/validator.js | 14 + 16 files changed, 327 insertions(+), 546 deletions(-) delete mode 100644 client/sagas/completion-saga.js create mode 100644 common/app/routes/challenges/redux/completion-saga.js diff --git a/client/sagas/completion-saga.js b/client/sagas/completion-saga.js deleted file mode 100644 index e66a0b2a5d..0000000000 --- a/client/sagas/completion-saga.js +++ /dev/null @@ -1,31 +0,0 @@ -import { Observable } from 'rx'; -import types from '../../common/app/routes/challenges/redux/types'; -import { makeToast } from '../../common/app/redux/actions'; - -import { randomCompliment } from '../../common/app/utils/get-words'; -/* -import { - updateOutput, - checkChallenge, - updateTests -} from '../../common/app/routes/challenges/redux/actions'; -*/ - -export default function completionSaga(actions$, getState) { - return actions$ - .filter(({ type }) => ( - type === types.checkChallenge - )) - .flatMap(() => { - const { tests } = getState().challengesApp; - if (tests.length > 1 && tests.every(test => test.pass && !test.err)) { - return Observable.of( - makeToast({ - type: 'success', - message: randomCompliment() - }) - ); - } - return Observable.just(null); - }); -} diff --git a/client/sagas/index.js b/client/sagas/index.js index 409cc245ee..e1c78d0243 100644 --- a/client/sagas/index.js +++ b/client/sagas/index.js @@ -5,7 +5,6 @@ import hardGoToSaga from './hard-go-to-saga'; import windowSaga from './window-saga'; import executeChallengeSaga from './execute-challenge-saga'; import frameSaga from './frame-saga'; -import completionSaga from './completion-saga'; import codeStorageSaga from './code-storage-saga'; export default [ @@ -16,6 +15,5 @@ export default [ windowSaga, executeChallengeSaga, frameSaga, - completionSaga, codeStorageSaga ]; diff --git a/common/app/App.jsx b/common/app/App.jsx index 9e1be2fc7d..0b16145fbb 100644 --- a/common/app/App.jsx +++ b/common/app/App.jsx @@ -1,5 +1,5 @@ import React, { PropTypes } from 'react'; -import { Row } from 'react-bootstrap'; +import { Button, Row } from 'react-bootstrap'; import { ToastMessage, ToastContainer } from 'react-toastr'; import { compose } from 'redux'; import { connect } from 'react-redux'; @@ -12,25 +12,41 @@ import { updateNavHeight } from './redux/actions'; +import { submitChallenge } from './routes/challenges/redux/actions'; + import Nav from './components/Nav'; +import { randomCompliment } from './utils/get-words'; const toastMessageFactory = React.createFactory(ToastMessage.animation); const mapStateToProps = createSelector( - state => state.app, - ({ + state => state.app.username, + state => state.app.points, + state => state.app.picture, + state => state.app.toast, + state => state.challengesApp.toast, + ( username, points, picture, - toast - }) => ({ + toast, + showChallengeComplete + ) => ({ username, points, picture, - toast + toast, + showChallengeComplete }) ); +const bindableActions = { + initWindowHeight, + updateNavHeight, + fetchUser, + submitChallenge +}; + const fetchContainerOptions = { fetchAction: 'fetchUser', isPrimed({ username }) { @@ -49,11 +65,19 @@ export class FreeCodeCamp extends React.Component { picture: PropTypes.string, toast: PropTypes.object, updateNavHeight: PropTypes.func, - initWindowHeight: PropTypes.func + initWindowHeight: PropTypes.func, + showChallengeComplete: PropTypes.number, + submitChallenge: PropTypes.func }; - componentWillReceiveProps({ toast: nextToast = {} }) { - const { toast = {} } = this.props; + componentWillReceiveProps({ + toast: nextToast = {}, + showChallengeComplete: nextCC = 0 + }) { + const { + toast = {}, + showChallengeComplete + } = this.props; if (toast.id !== nextToast.id) { this.refs.toaster[nextToast.type || 'success']( nextToast.message, @@ -64,12 +88,39 @@ export class FreeCodeCamp extends React.Component { } ); } + + if (nextCC !== showChallengeComplete) { + this.refs.toaster.success( + this.renderChallengeComplete(), + randomCompliment(), + { + closeButton: true, + timeOut: 0, + extendedTimeOut: 0 + } + ); + } } componentDidMount() { this.props.initWindowHeight(); } + renderChallengeComplete() { + const { submitChallenge } = this.props; + return ( + + ); + } + render() { const { username, points, picture, updateNavHeight } = this.props; const navProps = { username, points, picture, updateNavHeight }; @@ -91,7 +142,7 @@ export class FreeCodeCamp extends React.Component { const wrapComponent = compose( // connect Component to Redux Store - connect(mapStateToProps, { initWindowHeight, updateNavHeight, fetchUser }), + connect(mapStateToProps, bindableActions), // handles prefetching data contain(fetchContainerOptions) ); diff --git a/common/app/routes/Hikes/redux/answer-saga.js b/common/app/routes/Hikes/redux/answer-saga.js index b80ccf67fe..3b2ee5ad6a 100644 --- a/common/app/routes/Hikes/redux/answer-saga.js +++ b/common/app/routes/Hikes/redux/answer-saga.js @@ -92,7 +92,7 @@ function handleAnswer(action, getState) { title: 'Saved', type: 'info' }), - updatePoints(points), + updatePoints(points) ); }) .catch(createErrorObservable); diff --git a/common/app/routes/challenges/components/Show.jsx b/common/app/routes/challenges/components/Show.jsx index f6e0b2e2fb..3da7ee70d2 100644 --- a/common/app/routes/challenges/components/Show.jsx +++ b/common/app/routes/challenges/components/Show.jsx @@ -7,11 +7,12 @@ import PureComponent from 'react-pure-render/component'; import Classic from './classic/Classic.jsx'; import Step from './step/Step.jsx'; -import { fetchChallenge } from '../redux/actions'; +import { fetchChallenge, fetchChallenges } from '../redux/actions'; import { challengeSelector } from '../redux/selectors'; const bindableActions = { - fetchChallenge + fetchChallenge, + fetchChallenges }; const mapStateToProps = createSelector( @@ -32,14 +33,30 @@ const fetchOptions = { export class Challenges extends PureComponent { static displayName = 'Challenges'; - static propTypes = { isStep: PropTypes.bool }; + static propTypes = { + isStep: PropTypes.bool, + fetchChallenges: PropTypes.func + }; - render() { - if (this.props.isStep) { + componentDidMount() { + this.props.fetchChallenges(); + } + + renderView(isStep) { + if (isStep) { return ; } return ; } + + render() { + const { isStep } = this.props; + return ( +
+ { this.renderView(isStep) } +
+ ); + } } export default compose( diff --git a/common/app/routes/challenges/redux/actions.js b/common/app/routes/challenges/redux/actions.js index afec078e65..a8c23782bf 100644 --- a/common/app/routes/challenges/redux/actions.js +++ b/common/app/routes/challenges/redux/actions.js @@ -58,6 +58,18 @@ export const updateOutput = createAction(types.updateOutput, loggerToStr); export const checkChallenge = createAction(types.checkChallenge); +let id = 0; +export const showChallengeComplete = createAction( + types.showChallengeComplete, + () => { + id += 1; + return id; + } +); + +export const submitChallenge = createAction(types.submitChallenge); +export const moveToNextChallenge = createAction(types.moveToNextChallenge); + // code storage export const saveCode = createAction(types.saveCode); export const loadCode = createAction(types.loadCode); diff --git a/common/app/routes/challenges/redux/completion-saga.js b/common/app/routes/challenges/redux/completion-saga.js new file mode 100644 index 0000000000..76bf2fcd56 --- /dev/null +++ b/common/app/routes/challenges/redux/completion-saga.js @@ -0,0 +1,106 @@ +import { Observable } from 'rx'; +import { push } from 'react-router-redux'; +import types from './types'; +import { + showChallengeComplete, + moveToNextChallenge, + updateCurrentChallenge +} from './actions'; +import { + createErrorObservable, + makeToast, + updatePoints +} from '../../../redux/actions'; + +import { getNextChallenge } from '../utils'; +import { challengeSelector } from './selectors'; + +import { postJSON$ } from '../../../../utils/ajax-stream'; + +function completedChallenge(state) { + let body; + let isSignedIn = false; + try { + const { + challenge: { id } + } = challengeSelector(state); + const { + app: { isSignedIn: _isSignedId, csrfToken }, + challengesApp: { files } + } = state; + isSignedIn = _isSignedId; + body = { + id, + _csrf: csrfToken, + files + }; + } catch (err) { + return createErrorObservable(err); + } + const saveChallenge$ = postJSON$('/modern-challenge-completed', body) + .retry(3) + .flatMap(({ alreadyCompleted, points }) => { + return Observable.of( + makeToast({ + message: + 'Challenge saved.' + + (alreadyCompleted ? '' : ' First time Completed!'), + title: 'Saved', + type: 'info' + }), + updatePoints(points) + ); + }) + .catch(createErrorObservable); + + const challengeCompleted$ = Observable.of( + moveToNextChallenge(), + makeToast({ + title: 'Congratulations!', + message: isSignedIn ? ' Saving...' : 'Moving on to next challenge', + type: 'success' + }) + ); + return Observable.merge(saveChallenge$, challengeCompleted$); +} + +export default function completionSaga(actions$, getState) { + return actions$ + .filter(({ type }) => ( + type === types.checkChallenge || + type === types.submitChallenge || + type === types.moveToNextChallenge + )) + .flatMap(({ type }) => { + const state = getState(); + const { tests } = state.challengesApp; + if (tests.length > 1 && tests.every(test => test.pass && !test.err)) { + if (type === types.checkChallenge) { + return Observable.of( + showChallengeComplete() + ); + } + + if (type === types.submitChallenge) { + return completedChallenge(state); + } + + if (type === types.moveToNextChallenge) { + const nextChallenge = getNextChallenge( + state.challengesApp.challenge, + state.entities, + state.challengesApp.superBlocks + ); + return Observable.of( + updateCurrentChallenge(nextChallenge), + push(`/challenges/${nextChallenge.dashedName}`) + ); + } + } + return Observable.just(makeToast({ + message: 'Not all tests are passing', + title: 'Not quite there', + type: 'info' + })); + }); +} diff --git a/common/app/routes/challenges/redux/fetch-challenges-saga.js b/common/app/routes/challenges/redux/fetch-challenges-saga.js index c217f20863..5e6906dc2b 100644 --- a/common/app/routes/challenges/redux/fetch-challenges-saga.js +++ b/common/app/routes/challenges/redux/fetch-challenges-saga.js @@ -9,9 +9,10 @@ import { export default function fetchChallengesSaga(action$, getState, { services }) { return action$ - .filter( - ({ type }) => type === fetchChallenges || type === fetchChallenge - ) + .filter(({ type }) => ( + type === fetchChallenges || + type === fetchChallenge + )) .flatMap(({ type, payload })=> { const options = { service: 'map' }; if (type === fetchChallenge) { diff --git a/common/app/routes/challenges/redux/index.js b/common/app/routes/challenges/redux/index.js index 79b8c842e4..35165ea9f2 100644 --- a/common/app/routes/challenges/redux/index.js +++ b/common/app/routes/challenges/redux/index.js @@ -3,4 +3,6 @@ export reducer from './reducer'; export types from './types'; import fetchChallengesSaga from './fetch-challenges-saga'; -export const sagas = [ fetchChallengesSaga ]; +import completionSaga from './completion-saga'; + +export const sagas = [ fetchChallengesSaga, completionSaga ]; diff --git a/common/app/routes/challenges/redux/reducer.js b/common/app/routes/challenges/redux/reducer.js index 5f48df4db9..6f78ad1d28 100644 --- a/common/app/routes/challenges/redux/reducer.js +++ b/common/app/routes/challenges/redux/reducer.js @@ -19,7 +19,8 @@ const initialState = { previousStep: -1, filter: '', files: {}, - superBlocks: [] + superBlocks: [], + toast: 0 }; const mainReducer = handleActions( @@ -45,6 +46,10 @@ const mainReducer = handleActions( ...state, tests: state.tests.map(test => ({ ...test, err: false, pass: false })) }), + [types.showChallengeComplete]: (state, { payload: toast }) => ({ + ...state, + toast + }), // map [types.updateFilter]: (state, { payload = ''}) => ({ diff --git a/common/app/routes/challenges/redux/types.js b/common/app/routes/challenges/redux/types.js index a940a7ec95..fc6db656d2 100644 --- a/common/app/routes/challenges/redux/types.js +++ b/common/app/routes/challenges/redux/types.js @@ -29,6 +29,9 @@ export default createTypes([ 'initOutput', 'updateTests', 'checkChallenge', + 'showChallengeComplete', + 'submitChallenge', + 'moveToNextChallenge', // code storage 'saveCode', diff --git a/common/app/routes/challenges/utils.js b/common/app/routes/challenges/utils.js index 991afd8a91..7689ca88cf 100644 --- a/common/app/routes/challenges/utils.js +++ b/common/app/routes/challenges/utils.js @@ -1,5 +1,6 @@ import { compose } from 'redux'; import { BONFIRE, HTML, JS } from '../../utils/challengeTypes'; +import { dashify } from '../../../utils'; export function encodeScriptTags(value) { return value @@ -77,3 +78,34 @@ export function loggerToStr(args) { }) .reduce((str, arg) => str + arg + '\n', ''); } + +export function getFirstChallenge( + { superBlock, block, challenge }, + result +) { + return challenge[ + block[ + superBlock[ + result[0] + ].blocks[0] + ].challenges[0] + ]; +} + +export function getNextChallenge( + current, + entites, + superBlocks +) { + const { challenge: challengeMap, block: blockMap } = entites; + // find current challenge + // find current block + // find next challenge in block + const currentChallenge = challengeMap[current]; + if (currentChallenge) { + const block = blockMap[dashify(currentChallenge.block)]; + const index = block.challenges.indexOf(currentChallenge.dashedName); + return challengeMap[block.challenges[index + 1]]; + } + return getFirstChallenge(entites, superBlocks); +} diff --git a/common/utils/index.js b/common/utils/index.js index 85f49c563f..64e6beeac2 100644 --- a/common/utils/index.js +++ b/common/utils/index.js @@ -1,16 +1,7 @@ -export function nameSpacedTransformer(ns, transformer) { - if (!transformer) { - return nameSpacedTransformer.bind(null, ns); - } - return (state) => { - const newState = transformer(state[ns]); - - // nothing has changed - // noop - if (!newState || newState === state[ns]) { - return null; - } - - return { ...state, [ns]: newState }; - }; +export function dashify(str) { + return ('' + str) + .toLowerCase() + .replace(/\s/g, '-') + .replace(/[^a-z0-9\-\.]/gi, '') + .replace(/\:/g, ''); } diff --git a/gulpfile.js b/gulpfile.js index a4eb1c69c8..126e931acf 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -196,8 +196,7 @@ gulp.task('serve', function(cb) { var syncDepenedents = [ 'serve', 'js', - 'less', - 'dependents' + 'less' ]; gulp.task('sync', syncDepenedents, function() { diff --git a/server/boot/challenge.js b/server/boot/challenge.js index 380a6c9b03..65271d5d77 100644 --- a/server/boot/challenge.js +++ b/server/boot/challenge.js @@ -1,56 +1,11 @@ import _ from 'lodash'; -import dedent from 'dedent'; -import moment from 'moment'; -import { Observable, Scheduler } from 'rx'; +// 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 { ifNoUserSend } from '../utils/middleware'; -import { observeMethod } from '../utils/rx'; - -import { - ifNoUserSend, - flashIfNotVerified -} 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('fcc: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, @@ -103,434 +58,80 @@ function buildUserUpdate( 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; - } - return null; -} - -// 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( + '/modern-challenge-completed', + send200toNonUser, + modernChallengeCompleted + ); + 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', - flashIfNotVerified, - showChallenge - ); - app.use(router); - function redirectToCurrentChallenge(req, res, next) { - let challengeId = req.query.id || req.cookies.currentChallengeId; - // prevent serialized null/undefined from breaking things + function modernChallengeCompleted(req, res, next) { + const type = accepts(req).type('html', 'json', 'text'); + req.checkBody('id', 'id must be an ObjectId').isMongoId(); + req.checkBody('files', 'files must be an object with polyvinyls for keys') + .isFiles(); - 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: 'You\'ve completed the last challenge!' - }); - 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, ''); - const { user } = req; - - Observable.defer(() => { - if (user && user.getChallengeMap$) { - return user.getChallengeMap$().map(user); + const errors = req.validationErrors(true); + if (errors) { + if (type === 'json') { + return res.status(403).send({ errors }); } - return Observable.just(null); - }) - .flatMap(user => { - return getRenderData$(user, challenge$, challengeName, solution); + + log('errors', errors); + return res.sendStatus(403); + } + + const user = req.user; + return user.getChallengeMap$() + .flatMap(() => { + const completedDate = Date.now(); + const { + id, + files + } = req.body; + + const { alreadyCompleted, updateData } = buildUserUpdate( + user, + id, + { + id, + files, + completedDate + } + ); + + const points = alreadyCompleted ? user.points : user.points + 1; + + return user.update$(updateData) + .doOnNext(({ count }) => log('%s documents updated', count)) + .map(() => { + if (type === 'json') { + return res.json({ + points, + alreadyCompleted + }); + } + return res.sendStatus(200); + }); }) - .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, { - expires: new Date(2147483647000)}); - } - return res.render(view, data); - }, - next, - function() {} - ); + .subscribe(() => {}, next); } function completedChallenge(req, res, next) { @@ -662,24 +263,4 @@ module.exports = function(app) { }) .subscribe(() => {}, next); } - - function showMap(showAside, { user }, res, next) { - return Observable.defer(() => { - if (user && typeof user.getChallengeMap$ === 'function') { - return user.getChallengeMap$(); - } - return Observable.just({}); - }) - .flatMap(challengeMap => getSuperBlocks$(challenge$, challengeMap)) - .subscribe( - superBlocks => { - res.render('map/show', { - superBlocks, - title: 'A Map to Learn to Code and Become a Software Engineer', - showAside - }); - }, - next - ); - } }; diff --git a/server/middlewares/validator.js b/server/middlewares/validator.js index 7405525bef..80a7ede590 100644 --- a/server/middlewares/validator.js +++ b/server/middlewares/validator.js @@ -1,4 +1,7 @@ import validator from 'express-validator'; +import { isPoly } from '../../common/utils/polyvinyl'; + +const isObject = val => !!val && typeof val === 'object'; export default function() { return validator({ @@ -11,6 +14,17 @@ export default function() { }, isNumber(value) { return typeof value === 'number'; + }, + isFiles(value) { + if (!isObject(value)) { + return false; + } + const keys = Object.keys(value); + return !!keys.length && + // every key is a file + keys.every(key => isObject(value[key])) && + // every file has contents + keys.map(key => value[key]).every(file => isPoly(file)); } } });