PageRenderTime 48ms CodeModel.GetById 17ms RepoModel.GetById 0ms app.codeStats 0ms

/server/boot/challenge.js

https://gitlab.com/Aaeinstein54/FreeCodeCamp
JavaScript | 630 lines | 506 code | 73 blank | 51 comment | 54 complexity | 25bc6cbbdeffcf9c9e7c682af6525de5 MD5 | raw file
  1. import _ from 'lodash';
  2. import dedent from 'dedent';
  3. import moment from 'moment';
  4. import { Observable, Scheduler } from 'rx';
  5. import debugFactory from 'debug';
  6. import accepts from 'accepts';
  7. import {
  8. dasherize,
  9. unDasherize,
  10. getMDNLinks,
  11. randomVerb,
  12. randomPhrase,
  13. randomCompliment
  14. } from '../utils';
  15. import { saveUser, observeMethod } from '../utils/rx';
  16. import {
  17. ifNoUserSend
  18. } from '../utils/middleware';
  19. import getFromDisk$ from '../utils/getFromDisk$';
  20. const isDev = process.env.NODE_ENV !== 'production';
  21. const isBeta = !!process.env.BETA;
  22. const debug = debugFactory('freecc:challenges');
  23. const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i;
  24. const challengeView = {
  25. 0: 'challenges/showHTML',
  26. 1: 'challenges/showJS',
  27. 2: 'challenges/showVideo',
  28. 3: 'challenges/showZiplineOrBasejump',
  29. 4: 'challenges/showZiplineOrBasejump',
  30. 5: 'challenges/showBonfire',
  31. 7: 'challenges/showStep'
  32. };
  33. function isChallengeCompleted(user, challengeId) {
  34. if (!user) {
  35. return false;
  36. }
  37. return user.completedChallenges.some(challenge =>
  38. challenge.id === challengeId );
  39. }
  40. /*
  41. function numberWithCommas(x) {
  42. return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  43. }
  44. */
  45. function updateUserProgress(user, challengeId, completedChallenge) {
  46. let { completedChallenges } = user;
  47. const indexOfChallenge = _.findIndex(completedChallenges, {
  48. id: challengeId
  49. });
  50. const alreadyCompleted = indexOfChallenge !== -1;
  51. if (!alreadyCompleted) {
  52. user.progressTimestamps.push({
  53. timestamp: Date.now(),
  54. completedChallenge: challengeId
  55. });
  56. user.completedChallenges.push(completedChallenge);
  57. return user;
  58. }
  59. const oldCompletedChallenge = completedChallenges[indexOfChallenge];
  60. user.completedChallenges[indexOfChallenge] =
  61. Object.assign(
  62. {},
  63. completedChallenge,
  64. {
  65. completedDate: oldCompletedChallenge.completedDate,
  66. lastUpdated: completedChallenge.completedDate
  67. }
  68. );
  69. return { user, alreadyCompleted };
  70. }
  71. // small helper function to determine whether to mark something as new
  72. const dateFormat = 'MMM MMMM DD, YYYY';
  73. function shouldShowNew(element, block) {
  74. if (element) {
  75. return typeof element.releasedOn !== 'undefined' &&
  76. moment(element.releasedOn, dateFormat).diff(moment(), 'days') >= -60;
  77. }
  78. if (block) {
  79. const newCount = block.reduce((sum, { markNew }) => {
  80. if (markNew) {
  81. return sum + 1;
  82. }
  83. return sum;
  84. }, 0);
  85. return newCount / block.length * 100 === 100;
  86. }
  87. }
  88. // meant to be used with a filter method
  89. // on an array or observable stream
  90. // true if challenge should be passed through
  91. // false if should filter challenge out of array or stream
  92. function shouldNotFilterComingSoon({ isComingSoon, isBeta: challengeIsBeta }) {
  93. return isDev ||
  94. !isComingSoon ||
  95. (isBeta && challengeIsBeta);
  96. }
  97. function getRenderData$(user, challenge$, origChallengeName, solution) {
  98. const challengeName = unDasherize(origChallengeName)
  99. .replace(challengesRegex, '');
  100. const testChallengeName = new RegExp(challengeName, 'i');
  101. debug('looking for %s', testChallengeName);
  102. return challenge$
  103. .map(challenge => challenge.toJSON())
  104. .filter((challenge) => {
  105. return testChallengeName.test(challenge.name) &&
  106. shouldNotFilterComingSoon(challenge);
  107. })
  108. .last({ defaultValue: null })
  109. .flatMap(challenge => {
  110. if (challenge && isDev) {
  111. return getFromDisk$(challenge);
  112. }
  113. return Observable.just(challenge);
  114. })
  115. .flatMap(challenge => {
  116. // Handle not found
  117. if (!challenge) {
  118. debug('did not find challenge for ' + origChallengeName);
  119. return Observable.just({
  120. type: 'redirect',
  121. redirectUrl: '/map',
  122. message: dedent`
  123. We couldn't find a challenge with the name ${origChallengeName}.
  124. Please double check the name.
  125. `
  126. });
  127. }
  128. if (dasherize(challenge.name) !== origChallengeName) {
  129. let redirectUrl = `/challenges/${dasherize(challenge.name)}`;
  130. if (solution) {
  131. redirectUrl += `?solution=${encodeURIComponent(solution)}`;
  132. }
  133. return Observable.just({
  134. type: 'redirect',
  135. redirectUrl
  136. });
  137. }
  138. // save user does nothing if user does not exist
  139. return Observable.just({
  140. data: {
  141. ...challenge,
  142. // identifies if a challenge is completed
  143. isCompleted: isChallengeCompleted(user, challenge.id),
  144. // video challenges
  145. video: challenge.challengeSeed[0],
  146. // bonfires specific
  147. bonfires: challenge,
  148. MDNkeys: challenge.MDNlinks,
  149. MDNlinks: getMDNLinks(challenge.MDNlinks),
  150. // htmls specific
  151. verb: randomVerb(),
  152. phrase: randomPhrase(),
  153. compliment: randomCompliment(),
  154. // Google Analytics
  155. gaName: challenge.title + '~' + challenge.checksum
  156. }
  157. });
  158. });
  159. }
  160. function getCompletedChallengeIds(user = {}) {
  161. // if user
  162. // get the id's of all the users completed challenges
  163. return !user.completedChallenges ?
  164. [] :
  165. _.uniq(user.completedChallenges)
  166. .map(({ id, _id }) => id || _id);
  167. }
  168. // create a stream of an array of all the challenge blocks
  169. function getSuperBlocks$(challenge$, completedChallenges) {
  170. return challenge$
  171. // mark challenge completed
  172. .map(challengeModel => {
  173. const challenge = challengeModel.toJSON();
  174. if (completedChallenges.indexOf(challenge.id) !== -1) {
  175. challenge.completed = true;
  176. }
  177. challenge.markNew = shouldShowNew(challenge);
  178. return challenge;
  179. })
  180. // group challenges by block | returns a stream of observables
  181. .groupBy(challenge => challenge.block)
  182. // turn block group stream into an array
  183. .flatMap(block$ => block$.toArray())
  184. .map(blockArray => {
  185. const completedCount = blockArray.reduce((sum, { completed }) => {
  186. if (completed) {
  187. return sum + 1;
  188. }
  189. return sum;
  190. }, 0);
  191. const isBeta = _.every(blockArray, 'isBeta');
  192. const isComingSoon = _.every(blockArray, 'isComingSoon');
  193. const isRequired = _.every(blockArray, 'isRequired');
  194. blockArray = blockArray.map(challenge => {
  195. if (challenge.challengeType == 6 && challenge.type === 'hike') {
  196. challenge.url = '/videos/' + challenge.dashedName;
  197. } else {
  198. challenge.url = '/challenges/' + challenge.dashedName;
  199. }
  200. return challenge;
  201. });
  202. return {
  203. isBeta,
  204. isComingSoon,
  205. isRequired,
  206. name: blockArray[0].block,
  207. superBlock: blockArray[0].superBlock,
  208. dashedName: dasherize(blockArray[0].block),
  209. markNew: shouldShowNew(null, blockArray),
  210. challenges: blockArray,
  211. completed: completedCount / blockArray.length * 100,
  212. time: blockArray[0] && blockArray[0].time || '???'
  213. };
  214. })
  215. // filter out hikes
  216. .filter(({ superBlock }) => {
  217. return !(/hikes/i).test(superBlock);
  218. })
  219. // turn stream of blocks into a stream of an array
  220. .toArray()
  221. .flatMap(blocks => Observable.from(blocks, null, null, Scheduler.default))
  222. .groupBy(block => block.superBlock)
  223. .flatMap(blocks$ => blocks$.toArray())
  224. .map(superBlockArray => ({
  225. name: superBlockArray[0].superBlock,
  226. blocks: superBlockArray
  227. }))
  228. .toArray();
  229. }
  230. function getChallengeById$(challenge$, challengeId) {
  231. // return first challenge if no id is given
  232. if (!challengeId) {
  233. return challenge$
  234. .map(challenge => challenge.toJSON())
  235. .filter(shouldNotFilterComingSoon)
  236. // filter out hikes
  237. .filter(({ superBlock }) => !(/hikes/gi).test(superBlock))
  238. .first();
  239. }
  240. return challenge$
  241. .map(challenge => challenge.toJSON())
  242. // filter out challenges coming soon
  243. .filter(shouldNotFilterComingSoon)
  244. // filter out hikes
  245. .filter(({ superBlock }) => !(/hikes/gi).test(superBlock))
  246. .filter(({ id }) => id === challengeId);
  247. }
  248. function getNextChallenge$(challenge$, blocks$, challengeId) {
  249. return getChallengeById$(challenge$, challengeId)
  250. // now lets find the block it belongs to
  251. .flatMap(challenge => {
  252. // find the index of the block this challenge resides in
  253. const blockIndex$ = blocks$
  254. .findIndex(({ name }) => name === challenge.block);
  255. return blockIndex$
  256. .flatMap(blockIndex => {
  257. // could not find block?
  258. if (blockIndex === -1) {
  259. return Observable.throw(
  260. 'could not find challenge block for ' + challenge.block
  261. );
  262. }
  263. const firstChallengeOfNextBlock$ = blocks$
  264. .elementAt(blockIndex + 1, {})
  265. .map(({ challenges = [] }) => challenges[0]);
  266. return blocks$
  267. .filter(shouldNotFilterComingSoon)
  268. .elementAt(blockIndex)
  269. .flatMap(block => {
  270. // find where our challenge lies in the block
  271. const challengeIndex$ = Observable.from(
  272. block.challenges,
  273. null,
  274. null,
  275. Scheduler.default
  276. )
  277. .findIndex(({ id }) => id === challengeId);
  278. // grab next challenge in this block
  279. return challengeIndex$
  280. .map(index => {
  281. return block.challenges[index + 1];
  282. })
  283. .flatMap(nextChallenge => {
  284. if (!nextChallenge) {
  285. return firstChallengeOfNextBlock$;
  286. }
  287. return Observable.just(nextChallenge);
  288. });
  289. });
  290. });
  291. })
  292. .first();
  293. }
  294. module.exports = function(app) {
  295. const router = app.loopback.Router();
  296. const challengesQuery = {
  297. order: [
  298. 'superOrder ASC',
  299. 'order ASC',
  300. 'suborder ASC'
  301. ]
  302. };
  303. // challenge model
  304. const Challenge = app.models.Challenge;
  305. // challenge find query stream
  306. const findChallenge$ = observeMethod(Challenge, 'find');
  307. // create a stream of all the challenges
  308. const challenge$ = findChallenge$(challengesQuery)
  309. .flatMap(challenges => Observable.from(
  310. challenges,
  311. null,
  312. null,
  313. Scheduler.default
  314. ))
  315. // filter out all challenges that have isBeta flag set
  316. // except in development or beta site
  317. .filter(challenge => isDev || isBeta || !challenge.isBeta)
  318. .shareReplay();
  319. // create a stream of challenge blocks
  320. const blocks$ = challenge$
  321. .map(challenge => challenge.toJSON())
  322. .filter(shouldNotFilterComingSoon)
  323. // group challenges by block | returns a stream of observables
  324. .groupBy(challenge => challenge.block)
  325. // turn block group stream into an array
  326. .flatMap(blocks$ => blocks$.toArray())
  327. // turn array into stream of object
  328. .map(blocksArray => ({
  329. name: blocksArray[0].block,
  330. dashedName: dasherize(blocksArray[0].block),
  331. challenges: blocksArray,
  332. superBlock: blocksArray[0].superBlock,
  333. order: blocksArray[0].order
  334. }))
  335. // filter out hikes
  336. .filter(({ superBlock }) => {
  337. return !(/hikes/gi).test(superBlock);
  338. })
  339. .shareReplay();
  340. const firstChallenge$ = challenge$
  341. .first()
  342. .map(challenge => challenge.toJSON())
  343. .shareReplay();
  344. const lastChallenge$ = challenge$
  345. .last()
  346. .map(challenge => challenge.toJSON())
  347. .shareReplay();
  348. const send200toNonUser = ifNoUserSend(true);
  349. router.post(
  350. '/completed-challenge/',
  351. send200toNonUser,
  352. completedChallenge
  353. );
  354. router.post(
  355. '/completed-zipline-or-basejump',
  356. send200toNonUser,
  357. completedZiplineOrBasejump
  358. );
  359. router.get('/map', showMap.bind(null, false));
  360. router.get('/map-aside', showMap.bind(null, true));
  361. router.get(
  362. '/challenges/current-challenge',
  363. redirectToCurrentChallenge
  364. );
  365. router.get(
  366. '/challenges/next-challenge',
  367. redirectToNextChallenge
  368. );
  369. router.get('/challenges/:challengeName', showChallenge);
  370. app.use(router);
  371. function redirectToCurrentChallenge(req, res, next) {
  372. let challengeId = req.query.id || req.cookies.currentChallengeId;
  373. // prevent serialized null/undefined from breaking things
  374. if (challengeId === 'undefined' || challengeId === 'null') {
  375. challengeId = null;
  376. }
  377. getChallengeById$(challenge$, challengeId)
  378. .doOnNext(({ dashedName })=> {
  379. if (!dashedName) {
  380. debug('no challenge found for %s', challengeId);
  381. req.flash('info', {
  382. msg: `We coudn't find a challenge with the id ${challengeId}`
  383. });
  384. res.redirect('/map');
  385. }
  386. res.redirect('/challenges/' + dashedName);
  387. })
  388. .subscribe(() => {}, next);
  389. }
  390. function redirectToNextChallenge(req, res, next) {
  391. let challengeId = req.query.id || req.cookies.currentChallengeId;
  392. if (challengeId === 'undefined' || challengeId === 'null') {
  393. challengeId = null;
  394. }
  395. Observable.combineLatest(
  396. firstChallenge$,
  397. lastChallenge$
  398. )
  399. .flatMap(([firstChallenge, { id: lastChallengeId } ]) => {
  400. // no id supplied, load first challenge
  401. if (!challengeId) {
  402. return Observable.just(firstChallenge);
  403. }
  404. // camper just completed last challenge
  405. if (challengeId === lastChallengeId) {
  406. return Observable.just()
  407. .doOnCompleted(() => {
  408. req.flash('info', {
  409. msg: dedent`
  410. Once you have completed all of our challenges, you should
  411. join our <a href="https://gitter.im/freecodecamp/HalfWayClub"
  412. target="_blank">Half Way Club</a> and start getting
  413. ready for our nonprofit projects.
  414. `.split('\n').join(' ')
  415. });
  416. return res.redirect('/map');
  417. });
  418. }
  419. return getNextChallenge$(challenge$, blocks$, challengeId)
  420. .doOnNext(({ dashedName } = {}) => {
  421. if (!dashedName) {
  422. debug('no challenge found for %s', challengeId);
  423. res.redirect('/map');
  424. }
  425. res.redirect('/challenges/' + dashedName);
  426. });
  427. })
  428. .subscribe(() => {}, next);
  429. }
  430. function showChallenge(req, res, next) {
  431. const solution = req.query.solution;
  432. const challengeName = req.params.challengeName.replace(challengesRegex, '');
  433. getRenderData$(req.user, challenge$, challengeName, solution)
  434. .subscribe(
  435. ({ type, redirectUrl, message, data }) => {
  436. if (message) {
  437. req.flash('info', {
  438. msg: message
  439. });
  440. }
  441. if (type === 'redirect') {
  442. debug('redirecting to %s', redirectUrl);
  443. return res.redirect(redirectUrl);
  444. }
  445. var view = challengeView[data.challengeType];
  446. if (data.id) {
  447. res.cookie('currentChallengeId', data.id);
  448. }
  449. res.render(view, data);
  450. },
  451. next,
  452. function() {}
  453. );
  454. }
  455. function completedChallenge(req, res, next) {
  456. const type = accepts(req).type('html', 'json', 'text');
  457. const completedDate = Date.now();
  458. const {
  459. id,
  460. name,
  461. challengeType,
  462. solution,
  463. timezone
  464. } = req.body;
  465. const { alreadyCompleted } = updateUserProgress(
  466. req.user,
  467. id,
  468. {
  469. id,
  470. challengeType,
  471. solution,
  472. name,
  473. completedDate,
  474. verified: true
  475. }
  476. );
  477. if (timezone && (!req.user.timezone || req.user.timezone !== timezone)) {
  478. req.user.timezone = timezone;
  479. }
  480. let user = req.user;
  481. saveUser(req.user)
  482. .subscribe(
  483. function(user) {
  484. user = user;
  485. },
  486. next,
  487. function() {
  488. if (type === 'json') {
  489. return res.json({
  490. points: user.progressTimestamps.length,
  491. alreadyCompleted
  492. });
  493. }
  494. res.sendStatus(200);
  495. }
  496. );
  497. }
  498. function completedZiplineOrBasejump(req, res, next) {
  499. const { body = {} } = req;
  500. let completedChallenge;
  501. // backwards compatibility
  502. // please remove once in production
  503. // to allow users to transition to new client code
  504. if (body.challengeInfo) {
  505. if (!body.challengeInfo.challengeId) {
  506. req.flash('error', { msg: 'No id returned during save' });
  507. return res.sendStatus(403);
  508. }
  509. completedChallenge = {
  510. id: body.challengeInfo.challengeId,
  511. name: body.challengeInfo.challengeName || '',
  512. completedDate: Date.now(),
  513. challengeType: +body.challengeInfo.challengeType === 4 ? 4 : 3,
  514. solution: body.challengeInfo.publicURL,
  515. githubLink: body.challengeInfo.githubURL
  516. };
  517. } else {
  518. completedChallenge = _.pick(
  519. body,
  520. [ 'id', 'name', 'solution', 'githubLink', 'challengeType' ]
  521. );
  522. completedChallenge.challengeType = +completedChallenge.challengeType;
  523. completedChallenge.completedDate = Date.now();
  524. }
  525. if (
  526. !completedChallenge.solution ||
  527. // only basejumps require github links
  528. (
  529. completedChallenge.challengeType === 4 &&
  530. !completedChallenge.githubLink
  531. )
  532. ) {
  533. req.flash('errors', {
  534. msg: 'You haven\'t supplied the necessary URLs for us to inspect ' +
  535. 'your work.'
  536. });
  537. return res.sendStatus(403);
  538. }
  539. updateUserProgress(req.user, completedChallenge.id, completedChallenge);
  540. return saveUser(req.user)
  541. .doOnNext(() => res.status(200).send(true))
  542. .subscribe(() => {}, next);
  543. }
  544. function showMap(showAside, { user }, res, next) {
  545. getSuperBlocks$(challenge$, getCompletedChallengeIds(user))
  546. .subscribe(
  547. superBlocks => {
  548. res.render('map/show', {
  549. superBlocks,
  550. title: 'A Map to Learn to Code and Become a Software Engineer',
  551. showAside
  552. });
  553. },
  554. next
  555. );
  556. }
  557. };