diff --git a/client/commonFramework.js b/client/commonFramework.js index 8abcb6a9dc01d0..fcb1ccc58df08c 100644 --- a/client/commonFramework.js +++ b/client/commonFramework.js @@ -865,14 +865,53 @@ common.init.push((function() { } next(); }); - } - function handleActionClick() { - $(this) - .parent() - .find('.disabled') - .removeClass('disabled'); + function handleActionClick(e) { + var props = common.challengeSeed[0] || + { stepIndex: [] }; + + var $el = $(this); + var index = +$el.attr('id'); + var propIndex = props.stepIndex.indexOf(index); + + if (propIndex === -1) { + return $el + .parent() + .find('.disabled') + .removeClass('disabled'); + } + + // an API action + // prevent link from opening + e.preventDefault(); + var prop = props.properties[propIndex]; + var api = props.apis[propIndex]; + if (common[prop]) { + return $el + .parent() + .find('.disabled') + .removeClass('disabled'); + } + $ + .post(api) + .done(function(data) { + // assume a boolean indicates passing + if (typeof data === 'boolean') { + return $el + .parent() + .find('.disabled') + .removeClass('disabled'); + } + // assume api returns string when fails + $el + .parent() + .find('.disabled') + .replaceWith('
' + data + '
'); + }) + .fail(function() { + console.log('failed'); + }); } function handleFinishClick(e) { diff --git a/common/models/user.json b/common/models/user.json index c46d1c2f13bcf0..cb34389b80630a 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -102,14 +102,31 @@ }, "isLocked": { "type": "boolean", - "default": false + "default": false, + "description": "Campers profile does not show challenges to the public" }, "currentChallenge": { "type": {} }, "isUniqMigrated": { "type": "boolean", - "default": false + "default": false, + "description": "Campers completedChallenges array is free of duplicates" + }, + "isHonest": { + "type": "boolean", + "default": false, + "description": "Camper has signed academic honesty policy" + }, + "isFrontEndCert": { + "type": "boolean", + "defaut": false, + "description": "Camper is front end certified" + }, + "isFullStackCert": { + "type": "boolean", + "default": false, + "description": "Campers is full stack certified" }, "completedChallenges": { "type": [ diff --git a/public/fonts/saxmono.ttf b/public/fonts/saxmono.ttf new file mode 100755 index 00000000000000..76c77d64117497 Binary files /dev/null and b/public/fonts/saxmono.ttf differ diff --git a/seed/challenges/front-end-development-certificate.json b/seed/challenges/front-end-development-certificate.json index fa0bb1ce40bcf1..ac8b53ee213c20 100644 --- a/seed/challenges/front-end-development-certificate.json +++ b/seed/challenges/front-end-development-certificate.json @@ -5,14 +5,25 @@ { "id": "561add10cb82ac38a17513be", "title": "Claim Your Front End Development Certificate", - "difficulty": 0.00, - "challengeSeed": [], + "challengeSeed": [ + { + "properties": ["isHonest", "isFrontEndCert"], + "apis": ["/certificate/honest", "/certificate/verify/front-end"], + "stepIndex": [0, 1] + } + ], "description": [ [ "http://i.imgur.com/RlEk2IF.jpg", "a picture of Free Code Camp's 4 benefits: Get connected, Learn JavaScript, Build your Portfolio, Help nonprofits", "Welcome to Free Code Camp. We're an open source community of busy people who learn to code and help nonprofits.", - "" + "#" + ], + [ + "http://i.imgur.com/RlEk2IF.jpg", + "a picture of Free Code Camp's 4 benefits: Get connected, Learn JavaScript, Build your Portfolio, Help nonprofits", + "Welcome to Free Code Camp. We're an open source community of busy people who learn to code and help nonprofits.", + "#" ] ], "type": "Waypoint", diff --git a/seed/challenges/full-stack-development-certificate.json b/seed/challenges/full-stack-development-certificate.json index 901883aebcde56..bfb2f7df4adf0f 100644 --- a/seed/challenges/full-stack-development-certificate.json +++ b/seed/challenges/full-stack-development-certificate.json @@ -6,13 +6,25 @@ "id": "660add10cb82ac38a17513be", "title": "Claim Your Full Stack Development Certificate", "difficulty": 0.00, - "challengeSeed": [], + "challengeSeed": [ + { + "properties": ["isHonest", "isFullStackCert"], + "apis": ["/certificate/honest", "/certificate/verify/full-stack"], + "stepIndex": [0, 1] + } + ], "description": [ [ "http://i.imgur.com/RlEk2IF.jpg", "a picture of Free Code Camp's 4 benefits: Get connected, Learn JavaScript, Build your Portfolio, Help nonprofits", "Welcome to Free Code Camp. We're an open source community of busy people who learn to code and help nonprofits.", - "" + "#" + ], + [ + "http://i.imgur.com/RlEk2IF.jpg", + "a picture of Free Code Camp's 4 benefits: Get connected, Learn JavaScript, Build your Portfolio, Help nonprofits", + "Welcome to Free Code Camp. We're an open source community of busy people who learn to code and help nonprofits.", + "#" ] ], "type": "Waypoint", diff --git a/server/boot/certificate.js b/server/boot/certificate.js new file mode 100644 index 00000000000000..b6a3db94610c7e --- /dev/null +++ b/server/boot/certificate.js @@ -0,0 +1,131 @@ +import _ from 'lodash'; +import dedent from 'dedent'; +import { Observable } from 'rx'; +import debugFactory from 'debug'; + +import { + ifNoUser401, + ifNoUserSend +} from '../utils/middleware'; + +import { + saveUser, + observeQuery +} from '../utils/rx'; + +const frontEndChallangeId = '561add10cb82ac38a17513be'; +const fullStackChallangeId = '660add10cb82ac38a17513be'; +const debug = debugFactory('freecc:certification'); +const sendMessageToNonUser = ifNoUserSend( + 'must be logged in to complete.' +); + +function isCertified(frontEndIds, { completedChallenges, isFrontEndCert }) { + if (isFrontEndCert) { + return true; + } + return _.every(frontEndIds, ({ id }) => _.some(completedChallenges, { id })); +} + +export default function certificate(app) { + const router = app.loopback.Router(); + const { Challenge } = app.models; + + const frontEndChallangeIds$ = observeQuery( + Challenge, + 'findById', + frontEndChallangeId, + { + tests: true + } + ) + .map(({ tests = [] }) => tests) + .shareReplay(); + + const fullStackChallangeIds$ = observeQuery( + Challenge, + 'findById', + fullStackChallangeId, + { + tests: true + } + ) + .map(({ tests = [] }) => tests) + .shareReplay(); + + router.post( + '/certificate/verify/front-end', + ifNoUser401, + verifyCert + ); + + router.post( + '/certificate/verify/full-stack', + ifNoUser401, + verifyCert + ); + + router.post( + '/certificate/honest', + sendMessageToNonUser, + postHonest + ); + + app.use(router); + + function verifyCert(req, res, next) { + const isFront = req.path.split('/').pop() === 'front-end'; + Observable.just({}) + .flatMap(() => { + if (isFront) { + return frontEndChallangeIds$; + } + return fullStackChallangeIds$; + }) + .flatMap((tests) => { + const { user } = req; + if ( + isFront && !user.isFrontEndCert && isCertified(tests, user) || + !isFront && !user.isFullStackCert && isCertified(tests, user) + ) { + debug('certified'); + if (isFront) { + user.isFrontEndCert = true; + } else { + user.isFullStackCert = true; + } + return saveUser(user); + } + return Observable.just(user); + }) + .subscribe( + user => { + if ( + isFront && user.isFrontEndCert || + !isFront && user.isFullStackCert + ) { + return res.status(200).send(true); + } + return res.status(200).send( + dedent` + Looks like you have not completed the neccessary steps, + Please return the map + ` + ); + }, + next + ); + } + + function postHonest(req, res, next) { + const { user } = req; + user.isHonest = true; + saveUser(user) + .subscribe( + (user) => { + res.status(200).send(!!user.isHonest); + }, + next + ); + } +} diff --git a/server/boot/challenge.js b/server/boot/challenge.js index 15cb50785554cf..6a056b705d3e50 100644 --- a/server/boot/challenge.js +++ b/server/boot/challenge.js @@ -9,11 +9,10 @@ import utils from '../utils'; import { saveUser, observeMethod, - observableQueryFromModel + observeQuery } from '../utils/rx'; import { - userMigration, ifNoUserSend } from '../utils/middleware'; @@ -147,8 +146,6 @@ module.exports = function(app) { completedBonfire ); - // the follow routes are covered by userMigration - router.use(userMigration); router.get('/map', challengeMap); router.get( '/challenges/next-challenge', @@ -330,7 +327,7 @@ module.exports = function(app) { challengeType: 5 }; - observableQueryFromModel( + observeQuery( User, 'findOne', { where: { username: ('' + completedWith).toLowerCase() } } @@ -458,7 +455,7 @@ module.exports = function(app) { verified: false }; - observableQueryFromModel( + observeQuery( User, 'findOne', { where: { username: completedWith.toLowerCase() } } diff --git a/server/boot/user.js b/server/boot/user.js index 214c0b9264ec5c..9567a5066a8ff9 100644 --- a/server/boot/user.js +++ b/server/boot/user.js @@ -1,8 +1,11 @@ +import _ from 'lodash'; import dedent from 'dedent'; import moment from 'moment'; +import { Observable } from 'rx'; import debugFactory from 'debug'; import { ifNoUser401, ifNoUserRedirectTo } from '../utils/middleware'; +import { observeQuery } from '../utils/rx'; const debug = debugFactory('freecc:boot:user'); const daysBetween = 1.5; @@ -52,7 +55,16 @@ function dayDiff([head, tail]) { module.exports = function(app) { var router = app.loopback.Router(); var User = app.models.User; - // var Story = app.models.Story; + function findUserByUsername$(username, fields) { + return observeQuery( + User, + 'findOne', + { + where: { username }, + fields + } + ); + } router.get('/login', function(req, res) { res.redirect(301, '/signin'); @@ -85,7 +97,18 @@ module.exports = function(app) { ); router.get('/vote1', vote1); router.get('/vote2', vote2); - // Ensure this is the last route! + + // Ensure these are the last routes! + router.get( + '/:username/front-end-certification', + showCert + ); + + router.get( + '/:username/full-stack-certification', + showCert + ); + router.get('/:username', returnUser); app.use(router); @@ -184,14 +207,20 @@ module.exports = function(app) { return (obj.name || '').match(/^Waypoint/i); }); + debug('user is fec', profileUser.isFrontEndCert); res.render('account/show', { title: 'Camper ' + profileUser.username + '\'s portfolio', username: profileUser.username, name: profileUser.name, + isMigrationGrandfathered: profileUser.isMigrationGrandfathered, isGithubCool: profileUser.isGithubCool, isLocked: !!profileUser.isLocked, + isFrontEndCert: profileUser.isFrontEndCert, + isFullStackCert: profileUser.isFullStackCert, + isHonest: profileUser.isHonest, + location: profileUser.location, calender: data, @@ -216,6 +245,62 @@ module.exports = function(app) { ); } + function showCert(req, res, next) { + const username = req.params.username.toLowerCase(); + const { user } = req; + const showFront = req.path.split('/').pop() === 'front-end-certification'; + Observable.just(user) + .flatMap(user => { + if (user && user.username === username) { + return Observable.just(user); + } + return findUserByUsername$(username, { + isFrontEndCert: true, + isFullStackCert: true, + completedChallenges: true, + username: true, + name: true + }); + }) + .subscribe( + (user) => { + if (!user) { + req.flash('errors', { + msg: `404: We couldn't find the user ${username}` + }); + return res.redirect('/'); + } + if ( + showFront && user.isFrontEndCert || + !showFront && user.isFullStackCert + ) { + var { completedDate } = _.find(user.completedChallenges, { + id: '561add10cb82ac38a17513be' + }); + + return res.render( + showFront ? + 'certificate/front-end.jade' : + 'certificate/full-stack.jade', + { + username: user.username, + date: moment(new Date(completedDate)) + .format('MMMM, Do YYYY'), + name: user.name + } + ); + } + req.flash('errors', { + msg: showFront ? + `Looks like user ${username} is not Front End certified` : + `Looks like user ${username} is not Full Stack certified` + }); + res.redirect('/map'); + }, + next + ); + } + function toggleLockdownMode(req, res, next) { if (req.user.isLocked === true) { req.user.isLocked = false; @@ -297,11 +382,6 @@ module.exports = function(app) { }); } - /** - * POST /forgot - * Create a random token, then the send user an email with a reset link. - */ - function postForgot(req, res) { const errors = req.validationErrors(); const email = req.body.email.toLowerCase(); diff --git a/server/utils/middleware.js b/server/utils/middleware.js index 1edec7a59ba61d..3c541cbb11a823 100644 --- a/server/utils/middleware.js +++ b/server/utils/middleware.js @@ -1,60 +1,24 @@ -var R = require('ramda'); - -/* - * Middleware to migrate users from fragmented challenge structure to unified - * challenge structure - * - * @param req - * @param res - * @returns null - */ -exports.userMigration = function userMigration(req, res, next) { - if (!req.user || req.user.completedChallenges.length !== 0) { - return next(); - } - req.user.completedChallenges = R.filter(function(elem) { - // getting rid of undefined - return elem; - }, R.concat( - req.user.completedCoursewares, - req.user.completedBonfires.map(function(bonfire) { - return ({ - completedDate: bonfire.completedDate, - id: bonfire.id, - name: bonfire.name, - completedWith: bonfire.completedWith, - solution: bonfire.solution, - githubLink: '', - verified: false, - challengeType: 5 - }); - }) - ) - ); - return next(); -}; - -exports.ifNoUserRedirectTo = function ifNoUserRedirectTo(url) { +export function ifNoUserRedirectTo(url) { return function(req, res, next) { if (req.user) { return next(); } return res.redirect(url); }; -}; +} -exports.ifNoUserSend = function ifNoUserSend(sendThis) { +export function ifNoUserSend(sendThis) { return function(req, res, next) { if (req.user) { return next(); } return res.status(200).send(sendThis); }; -}; +} -exports.ifNoUser401 = function ifNoUser401(req, res, next) { +export function ifNoUser401(req, res, next) { if (req.user) { return next(); } return res.status(401).end(); -}; +} diff --git a/server/utils/rx.js b/server/utils/rx.js index d3b1ac41b04bad..68086d25634439 100644 --- a/server/utils/rx.js +++ b/server/utils/rx.js @@ -1,7 +1,9 @@ -var Rx = require('rx'); -var debug = require('debug')('freecc:rxUtils'); +import Rx from 'rx'; +import debugFactory from 'debug'; -exports.saveInstance = function saveInstance(instance) { +const debug = debugFactory('freecc:rxUtils'); + +export function saveInstance(instance) { return new Rx.Observable.create(function(observer) { if (!instance || typeof instance.save !== 'function') { debug('no instance or save method'); @@ -17,16 +19,15 @@ exports.saveInstance = function saveInstance(instance) { observer.onCompleted(); }); }); -}; +} // alias saveInstance -exports.saveUser = exports.saveInstance; +export const saveUser = saveInstance; -exports.observeQuery = exports.observableQueryFromModel = - function observableQueryFromModel(Model, method, query) { - return Rx.Observable.fromNodeCallback(Model[method], Model)(query); - }; +export function observeQuery(Model, method, query) { + return Rx.Observable.fromNodeCallback(Model[method], Model)(query); +} -exports.observeMethod = function observeMethod(context, methodName) { +export function observeMethod(context, methodName) { return Rx.Observable.fromNodeCallback(context[methodName], context); -}; +} diff --git a/server/views/account/show.jade b/server/views/account/show.jade index 5f07918815edb8..530f74b746577c 100644 --- a/server/views/account/show.jade +++ b/server/views/account/show.jade @@ -58,8 +58,13 @@ block content h1.flat-top.wrappable= name h1.flat-top.wrappable= location h1.flat-top.text-primary= "[ " + (progressTimestamps.length) + " ]" + if isFrontEndCert + a.btn.btn-primary(href='/' + username + '/front-end-certification') View My Front End Development Certification + if isFullStackCert + .button-spacer + a.btn.btn-success(href='/' + username + '/full-stack-certification') View My Full Stack Development Certification if (user && user.username !== username) - a.btn.btn-lg.btn-block.btn-twitter.btn-link-social(href='/link/twitter') + a.btn.btn-lg.btn-block.btn-twitter.btn-link-social(href='/leaderboard/add?username=#{username}') i.fa.fa-plus-square | Add them to my personal leaderboard diff --git a/server/views/certificate/font.jade b/server/views/certificate/font.jade new file mode 100644 index 00000000000000..54b76b935f2eec --- /dev/null +++ b/server/views/certificate/font.jade @@ -0,0 +1,45 @@ +style. + @font-face { + font-family: "Sax Mono"; + src: url("/fonts/saxmono.ttf") format("truetype"); + } + + body { + display: inline-block; + font-family: "Sax Mono", monospace; + margin: 0; + position: absolute; + text-align: center; + } + + .img-abs { + left 0; + position: relative; + top: 0; + width: 2000px + } + + .cert-name { + font-size: 64px; + left: 1000px; + position: absolute; + top: 704px; + z-index: 1000; + } + + .cert-date { + font-size: 60px; + left: 760px; + position: absolute; + top: 1004.8px; + z-index: 1000; + } + + .cert-link { + font-size: 22px; + left: 120px; + position: absolute; + top: 1488px; + z-index: 1000; + } + diff --git a/server/views/certificate/front-end.jade b/server/views/certificate/front-end.jade new file mode 100644 index 00000000000000..379dfb7dd1f0c5 --- /dev/null +++ b/server/views/certificate/front-end.jade @@ -0,0 +1,6 @@ +include font +#name.cert-name= name +img#cert.img-abs(src='http://i.imgur.com/ToFZKBd.jpg') +.cert-date= date +.cert-link verify this certification at: http://freecodecamp.com/#{username}/front-end-certification +include script diff --git a/server/views/certificate/full-stack.jade b/server/views/certificate/full-stack.jade new file mode 100644 index 00000000000000..95c94a6eb0fc30 --- /dev/null +++ b/server/views/certificate/full-stack.jade @@ -0,0 +1,6 @@ +include font +#name.cert-name= name +img#cert.img-abs(src='http://i.imgur.com/Z4PgjBQ.jpg') +.cert-date= date +.cert-link verify this certification at: http://freecodecamp.com/#{username}/full-stack-certification +include script diff --git a/server/views/certificate/index.jade b/server/views/certificate/index.jade new file mode 100644 index 00000000000000..51e0294db20ff7 --- /dev/null +++ b/server/views/certificate/index.jade @@ -0,0 +1,7 @@ +extends ../layout +block content + .panel.panel-info + .panel-heading.text-center + h1 Certificate + .panel-body + p foo diff --git a/server/views/certificate/script.jade b/server/views/certificate/script.jade new file mode 100644 index 00000000000000..ccb83323c545d1 --- /dev/null +++ b/server/views/certificate/script.jade @@ -0,0 +1,8 @@ +script. + (function() { + var containerWidth = document.getElementById('cert').offsetWidth; + var nameDiv = document.getElementById('name'); + var nameWidth = nameDiv.offsetWidth; + console.log(containerWidth, nameWidth); + nameDiv.style.left = ((containerWidth - nameWidth) / 2) + 15; + })(); diff --git a/server/views/coursewares/showStep.jade b/server/views/coursewares/showStep.jade index f89d65eda2a1f9..fa8f11431a7063 100644 --- a/server/views/coursewares/showStep.jade +++ b/server/views/coursewares/showStep.jade @@ -9,7 +9,7 @@ block content .caption p.large-p= step[2] if step[3] - a.btn.btn-block.btn-primary.challenge-step-btn-action(href='#{step[3]}' target='_blank') Go To Link + a.btn.btn-block.btn-primary.challenge-step-btn-action(id='#{index}' href='#{step[3]}' target='_blank') Go To Link if index + 1 === description.length .btn.btn-block.btn-primary.challenge-step-btn-finish(id='last' class=step[3] ? 'disabled' : '') Finish challenge else @@ -32,8 +32,12 @@ block content a.btn.btn-lg.btn-primary.btn-block(href='/challenges/next-challenge?id=' + challengeId) Go to my next challenge script(src=rev('/js', 'commonFramework.js')) script. - var common = common || { init: [] }; + var common = window.common || { init: [] }; common.challengeId = !{JSON.stringify(challengeId)}; common.challengeName = !{JSON.stringify(name)}; common.challengeType = 7; common.dashedName = !{JSON.stringify(dashedName || '')}; + common.isHonest = !{JSON.stringify(isHonest || false)}; + common.isFrontEndCert = !{JSON.stringify(isFrontEndCert || false)}; + common.isFullStackCert = !{JSON.stringify(isFullStackCert || false)}; + common.challengeSeed = !{JSON.stringify(challengeSeed || [])};