diff --git a/common/app/Map/redux/fetch-map-ui-epic.js b/common/app/Map/redux/fetch-map-ui-epic.js index 9933f1121e..f0cf90eec7 100644 --- a/common/app/Map/redux/fetch-map-ui-epic.js +++ b/common/app/Map/redux/fetch-map-ui-epic.js @@ -15,7 +15,7 @@ export default function fetchMapUiEpic( _, { services } ) { - return actions.do(console.log)::ofType( + return actions::ofType( appTypes.appMounted, types.fetchMapUi.start ) @@ -25,7 +25,6 @@ export default function fetchMapUiEpic( }; return services.readService$(options) .retry(3) - .do(console.info) .map(({ entities, ...res }) => ({ entities: shapeChallenges( entities, diff --git a/common/app/routes/Profile/components/SocialIcons.jsx b/common/app/routes/Profile/components/SocialIcons.jsx index d8b1b3bb78..a32ae4d5ae 100644 --- a/common/app/routes/Profile/components/SocialIcons.jsx +++ b/common/app/routes/Profile/components/SocialIcons.jsx @@ -12,7 +12,7 @@ import { userByNameSelector } from '../../../redux'; const propTypes = { email: PropTypes.string, - githubURL: PropTypes.string, + githubProfile: PropTypes.string, isGithub: PropTypes.bool, isLinkedIn: PropTypes.bool, isTwitter: PropTypes.bool, @@ -26,7 +26,7 @@ const propTypes = { const mapStateToProps = createSelector( userByNameSelector, ({ - githubURL, + githubProfile, isLinkedIn, isGithub, isTwitter, @@ -35,7 +35,7 @@ const mapStateToProps = createSelector( twitter, website }) => ({ - githubURL, + githubProfile, isLinkedIn, isGithub, isTwitter, @@ -97,7 +97,7 @@ function TwitterIcon(handle) { function SocialIcons(props) { const { - githubURL, + githubProfile, isLinkedIn, isGithub, isTwitter, @@ -121,7 +121,7 @@ function SocialIcons(props) { isLinkedIn ? LinkedInIcon(linkedIn) : null } { - isGithub ? githubIcon(githubURL) : null + isGithub ? githubIcon(githubProfile) : null } { isWebsite ? WebsiteIcon(website) : null diff --git a/common/app/routes/Profile/components/Timeline.jsx b/common/app/routes/Profile/components/Timeline.jsx index a7f4516a60..86e01fb3f1 100644 --- a/common/app/routes/Profile/components/Timeline.jsx +++ b/common/app/routes/Profile/components/Timeline.jsx @@ -5,8 +5,6 @@ import { connect } from 'react-redux'; import format from 'date-fns/format'; import { reverse, sortBy } from 'lodash'; import { - Button, - Modal, Table } from 'react-bootstrap'; @@ -16,48 +14,40 @@ import { homeURL } from '../../../../utils/constantStrings.json'; import blockNameify from '../../../utils/blockNameify'; import { FullWidthRow } from '../../../helperComponents'; import { Link } from '../../../Router'; -import SolutionViewer from '../../Settings/components/SolutionViewer.jsx'; const mapStateToProps = createSelector( challengeIdToNameMapSelector, userByNameSelector, ( idToNameMap, - { challengeMap: completedMap = {}, username } + { completedChallenges: completedMap = [] } ) => ({ completedMap, - idToNameMap, - username + idToNameMap }) ); const propTypes = { - completedMap: PropTypes.shape({ - id: PropTypes.string, - completedDate: PropTypes.number, - lastUpdated: PropTypes.number - }), - idToNameMap: PropTypes.objectOf(PropTypes.string), - username: PropTypes.string + completedMap: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string, + completedDate: PropTypes.number, + challengeType: PropTypes.number + }) + ), + idToNameMap: PropTypes.objectOf(PropTypes.string) }; class Timeline extends PureComponent { constructor(props) { super(props); - this.state = { - solutionToView: null, - solutionOpen: false - }; - - this.closeSolution = this.closeSolution.bind(this); this.renderCompletion = this.renderCompletion.bind(this); - this.viewSolution = this.viewSolution.bind(this); } renderCompletion(completed) { const { idToNameMap } = this.props; - const { id, completedDate, lastUpdated, files } = completed; + const { id, completedDate } = completed; return ( { blockNameify(idToNameMap[id]) } @@ -68,53 +58,12 @@ class Timeline extends PureComponent { } - - { - lastUpdated ? - : - '' - } - - - { - files ? - : - '' - } - ); } - viewSolution(id) { - this.setState(state => ({ - ...state, - solutionToView: id, - solutionOpen: true - })); - } - - closeSolution() { - this.setState(state => ({ - ...state, - solutionToView: null, - solutionOpen: false - })); - } - render() { - const { completedMap, idToNameMap, username } = this.props; - const { solutionToView: id, solutionOpen } = this.state; + const { completedMap, idToNameMap } = this.props; if (!Object.keys(idToNameMap).length) { return null; } @@ -122,7 +71,7 @@ class Timeline extends PureComponent {

Timeline

{ - Object.keys(completedMap).length === 0 ? + completedMap.length === 0 ?

No challenges have been completed yet.  @@ -134,45 +83,21 @@ class Timeline extends PureComponent { Challenge First Completed - Last Changed - { reverse( sortBy( - Object.keys(completedMap) - .filter(key => key in idToNameMap) - .map(key => completedMap[key]), + completedMap, [ 'completedDate' ] - ) + ).filter(({id}) => id in idToNameMap) ) .map(this.renderCompletion) } } - { - id && - - - - { `${username}'s Solution to ${blockNameify(idToNameMap[id])}` } - - - - - - - - - - } ); } diff --git a/common/app/routes/Settings/components/Cert-Settings.jsx b/common/app/routes/Settings/components/Cert-Settings.jsx index 2e3d1c0c2a..c2c3582aa5 100644 --- a/common/app/routes/Settings/components/Cert-Settings.jsx +++ b/common/app/routes/Settings/components/Cert-Settings.jsx @@ -27,7 +27,7 @@ const mapStateToProps = createSelector( projectsSelector, ( { - challengeMap, + completedChallenges, isRespWebDesignCert, is2018DataVisCert, isFrontEndLibsCert, @@ -45,7 +45,7 @@ const mapStateToProps = createSelector( legacyProjects: projects.filter(p => p.superBlock.includes('legacy')), modernProjects: projects.filter(p => !p.superBlock.includes('legacy')), userProjects: projects - .map(block => buildUserProjectsMap(block, challengeMap)) + .map(block => buildUserProjectsMap(block, completedChallenges)) .reduce((projects, current) => ({ ...projects, ...current diff --git a/common/app/routes/Settings/components/Internet-Settings.jsx b/common/app/routes/Settings/components/Internet-Settings.jsx index ee96f1983e..245c79e998 100644 --- a/common/app/routes/Settings/components/Internet-Settings.jsx +++ b/common/app/routes/Settings/components/Internet-Settings.jsx @@ -13,13 +13,13 @@ import { updateUserBackend } from '../redux'; const mapStateToProps = createSelector( userSelector, ({ - githubURL = '', + githubProfile = '', linkedin = '', twitter = '', website = '' }) => ({ initialValues: { - githubURL, + githubProfile, linkedin, twitter, website @@ -27,7 +27,7 @@ const mapStateToProps = createSelector( }) ); -const formFields = [ 'githubURL', 'linkedin', 'twitter', 'website' ]; +const formFields = [ 'githubProfile', 'linkedin', 'twitter', 'website' ]; function mapDispatchToProps(dispatch) { return bindActionCreators({ @@ -37,7 +37,7 @@ function mapDispatchToProps(dispatch) { const propTypes = { fields: PropTypes.object, - githubURL: PropTypes.string, + githubProfile: PropTypes.string, handleSubmit: PropTypes.func.isRequired, linkedin: PropTypes.string, twitter: PropTypes.string, diff --git a/common/app/routes/Settings/redux/index.js b/common/app/routes/Settings/redux/index.js index 1128f8d008..9d19cf23d0 100644 --- a/common/app/routes/Settings/redux/index.js +++ b/common/app/routes/Settings/redux/index.js @@ -36,7 +36,7 @@ export const types = createTypes([ createAsyncTypes('updateMyPortfolio'), 'updateNewUsernameValidity', createAsyncTypes('validateUsername'), - createAsyncTypes('refetchChallengeMap'), + createAsyncTypes('refetchCompletedChallenges'), createAsyncTypes('deleteAccount'), createAsyncTypes('resetProgress'), @@ -103,8 +103,8 @@ export const updateNewUsernameValidity = createAction( types.updateNewUsernameValidity ); -export const refetchChallengeMap = createAction( - types.refetchChallengeMap.start +export const refetchCompletedChallenges = createAction( + types.refetchCompletedChallenges.start ); export const validateUsername = createAction(types.validateUsername.start); diff --git a/common/app/routes/Settings/redux/update-user-epic.js b/common/app/routes/Settings/redux/update-user-epic.js index c999aa96f7..e01258b0b5 100644 --- a/common/app/routes/Settings/redux/update-user-epic.js +++ b/common/app/routes/Settings/redux/update-user-epic.js @@ -3,7 +3,7 @@ import { combineEpics, ofType } from 'redux-epic'; import { pick } from 'lodash'; import { types, - refetchChallengeMap, + refetchCompletedChallenges, updateUserBackendComplete, updateMyPortfolioComplete } from './'; @@ -90,7 +90,7 @@ function backendUserUpdateEpic(actions$, { getState }) { const complete = actions$::ofType(types.updateUserBackend.complete) .flatMap(({ payload: { message } }) => Observable.if( () => message.includes('project'), - Observable.of(refetchChallengeMap(), makeToast({ message })), + Observable.of(refetchCompletedChallenges(), makeToast({ message })), Observable.of(makeToast({ message })) ) ); @@ -98,16 +98,16 @@ function backendUserUpdateEpic(actions$, { getState }) { return Observable.merge(server, optimistic, complete); } -function refetchChallengeMapEpic(actions$, { getState }) { - return actions$::ofType(types.refetchChallengeMap.start) +function refetchCompletedChallengesEpic(actions$, { getState }) { + return actions$::ofType(types.refetchCompletedChallenges.start) .flatMap(() => { const { app: { csrfToken: _csrf } } = getState(); const username = usernameSelector(getState()); - return postJSON$('/refetch-user-challenge-map', { _csrf }) - .map(({ challengeMap }) => - updateMultipleUserFlags({ username, flags: { challengeMap } }) + return postJSON$('/refetch-user-completed-challenges', { _csrf }) + .map(({ completedChallenges }) => + updateMultipleUserFlags({ username, flags: { completedChallenges } }) ) .catch(createErrorObservable); }); @@ -190,7 +190,7 @@ function updateUserEmailEpic(actions, { getState }) { export default combineEpics( backendUserUpdateEpic, - refetchChallengeMapEpic, + refetchCompletedChallengesEpic, updateMyPortfolioEpic, updateUserEmailEpic ); diff --git a/common/app/routes/Settings/utils/buildUserProjectsMap.js b/common/app/routes/Settings/utils/buildUserProjectsMap.js index 5629a94939..9aeb3d4fe0 100644 --- a/common/app/routes/Settings/utils/buildUserProjectsMap.js +++ b/common/app/routes/Settings/utils/buildUserProjectsMap.js @@ -1,6 +1,8 @@ +import { find } from 'lodash'; + export const jsProjectSuperBlock = 'javascript-algorithms-and-data-structures'; -export function buildUserProjectsMap(projectBlock, challengeMap) { +export function buildUserProjectsMap(projectBlock, completedChallenges) { const { challenges, superBlock @@ -8,7 +10,10 @@ export function buildUserProjectsMap(projectBlock, challengeMap) { return { [superBlock]: challenges.reduce((solutions, current) => { const { id } = current; - const completed = challengeMap[id]; + const completed = find( + completedChallenges, + ({ id: completedId }) => completedId === id + ); let solution = ''; if (superBlock === jsProjectSuperBlock) { solution = {}; diff --git a/common/models/user.js b/common/models/user.js index 4448e70550..e703151c88 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -1,5 +1,5 @@ import { Observable } from 'rx'; -import uuid from 'uuid'; +import uuid from 'uuid/v4'; import moment from 'moment'; import dedent from 'dedent'; import debugFactory from 'debug'; @@ -7,6 +7,7 @@ import { isEmail } from 'validator'; import path from 'path'; import loopback from 'loopback'; import _ from 'lodash'; +import { ObjectId } from 'mongodb'; import { themes } from '../utils/themes'; import { saveUser, observeMethod } from '../../server/utils/rx.js'; @@ -41,51 +42,51 @@ function destroyAll(id, Model) { )({ userId: id }); } -function buildChallengeMapUpdate(challengeMap, project) { +function buildCompletedChallengesUpdate(completedChallenges, project) { const key = Object.keys(project)[0]; const solutions = project[key]; - const currentChallengeMap = { ...challengeMap }; - const currentCompletedProjects = _.pick( - currentChallengeMap, - Object.keys(solutions) - ); + const currentCompletedChallenges = [ ...completedChallenges ]; + const currentCompletedProjects = currentCompletedChallenges + .filter(({id}) => Object.keys(solutions).includes(id)); const now = Date.now(); const update = Object.keys(solutions).reduce((update, currentId) => { + const indexOfCurrentId = _.findIndex( + currentCompletedProjects, + ({id}) => id === currentId + ); + const isCurrentlyCompleted = indexOfCurrentId !== -1; if ( - currentId in currentCompletedProjects && - currentCompletedProjects[currentId].solution !== solutions[currentId] + isCurrentlyCompleted && + currentCompletedProjects[ + indexOfCurrentId + ].solution !== solutions[currentId] ) { - return { - ...update, - [currentId]: { - ...currentCompletedProjects[currentId], - solution: solutions[currentId], - numOfAttempts: currentCompletedProjects[currentId].numOfAttempts + 1 - } + update[indexOfCurrentId] = { + ...update[indexOfCurrentId], + solution: solutions[currentId] }; } - if (!(currentId in currentCompletedProjects)) { - return { + if (!isCurrentlyCompleted) { + return [ ...update, - [currentId]: { + { id: currentId, solution: solutions[currentId], challengeType: 3, - completedDate: now, - numOfAttempts: 1 + completedDate: now } - }; + ]; } return update; - }, {}); - const updatedExisting = { - ...currentCompletedProjects, - ...update - }; - return { - ...currentChallengeMap, - ...updatedExisting - }; + }, currentCompletedProjects); + const updatedExisting = _.uniqBy( + [ + ...update, + ...currentCompletedChallenges + ], + 'id' + ); + return updatedExisting; } function isTheSame(val1, val2) { @@ -219,7 +220,14 @@ module.exports = function(User) { // assign random username to new users // actual usernames will come from github // use full uuid to ensure uniqueness - user.username = 'fcc' + uuid.v4(); + user.username = 'fcc' + uuid(); + + if (!user.externalId) { + user.externalId = uuid(); + } + if (!user.unsubscribeId) { + user.unsubscribeId = new ObjectId(); + } if (!user.progressTimestamps) { user.progressTimestamps = []; @@ -269,7 +277,15 @@ module.exports = function(User) { } if (user.progressTimestamps.length === 0) { - user.progressTimestamps.push({ timestamp: Date.now() }); + user.progressTimestamps.push(Date.now()); + } + + if (!user.externalId) { + user.externalId = uuid(); + } + + if (!user.unsubscribeId) { + user.unsubscribeId = new ObjectId(); } }) .ignoreElements(); @@ -645,9 +661,11 @@ module.exports = function(User) { }); }; - User.prototype.requestChallengeMap = function requestChallengeMap() { - return this.getChallengeMap$(); - }; + function requestCompletedChallenges() { + return this.getCompletedChallenges$(); + } + + User.prototype.requestCompletedChallenges = requestCompletedChallenges; User.prototype.requestUpdateFlags = function requestUpdateFlags(values) { const flagsToCheck = Object.keys(values); @@ -715,10 +733,10 @@ module.exports = function(User) { User.prototype.updateMyProjects = function updateMyProjects(project) { const updateData = {}; - return this.getChallengeMap$() - .flatMap(challengeMap => { - updateData.challengeMap = buildChallengeMapUpdate( - challengeMap, + return this.getCompletedChallenges$() + .flatMap(completedChallenges => { + updateData.completedChallenges = buildCompletedChallengesUpdate( + completedChallenges, project ); return this.update$(updateData); @@ -767,18 +785,18 @@ module.exports = function(User) { if (!user) { return Observable.of({}); } - const { challengeMap, progressTimestamps, timezone } = user; + const { completedChallenges, progressTimestamps, timezone } = user; return Observable.of({ entities: { user: { [user.username]: { ..._.pick(user, publicUserProps), - isGithub: !!user.githubURL, + isGithub: !!user.githubProfile, isLinkedIn: !!user.linkedIn, isTwitter: !!user.twitter, isWebsite: !!user.website, points: progressTimestamps.length, - challengeMap, + completedChallenges, ...getProgress(progressTimestamps, timezone), ...normaliseUserFields(user) } @@ -1000,16 +1018,16 @@ module.exports = function(User) { return user.progressTimestamps; }); }; - User.prototype.getChallengeMap$ = function getChallengeMap$() { + User.prototype.getCompletedChallenges$ = function getCompletedChallenges$() { const id = this.getId(); const filter = { where: { id }, - fields: { challengeMap: true } + fields: { completedChallenges: true } }; return this.constructor.findOne$(filter) .map(user => { - this.challengeMap = user.challengeMap; - return user.challengeMap; + this.completedChallenges = user.completedChallenges; + return user.completedChallenges; }); }; diff --git a/common/models/user.json b/common/models/user.json index bbe76f9d31..1c14c82781 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -29,8 +29,17 @@ "emailAuthLinkTTL": { "type": "date" }, + "externalId": { + "type": "string", + "description": "A uuid/v4 used to identify user accounts" + }, + "unsubscribeId": { + "type": "string", + "description": "An ObjectId used to unsubscribe users from the mailing list(s)" + }, "password": { - "type": "string" + "type": "string", + "description": "No longer used for new accounts" }, "progressTimestamps": { "type": "array", @@ -46,35 +55,15 @@ "description": "Users who are confirmed to have broken academic honesty policy are marked as cheaters", "default": false }, - "isGithubCool": { - "type": "boolean", - "default": false - }, - "githubId": { + "githubProfile": { "type": "string" }, - "githubURL": { - "type": "string" - }, - "githubEmail": { - "type": "string" - }, - "joinedGithubOn": { - "type": "date" - }, "website": { "type": "string" }, - "githubProfile": { - "type": "string" - }, "_csrf": { "type": "string" }, - "isMigrationGrandfathered": { - "type": "boolean", - "default": false - }, "username": { "type": "string", "index": { @@ -114,22 +103,6 @@ "twitter": { "type": "string" }, - "currentStreak": { - "type": "number", - "default": 0 - }, - "longestStreak": { - "type": "number", - "default": 0 - }, - "sendMonthlyEmail": { - "type": "boolean", - "default": true - }, - "sendNotificationEmail": { - "type": "boolean", - "default": true - }, "sendQuincyEmail": { "type": "boolean", "default": true @@ -144,15 +117,6 @@ "description": "The challenge last visited by the user", "default": "" }, - "currentChallenge": { - "type": {}, - "description": "deprecated" - }, - "isUniqMigrated": { - "type": "boolean", - "description": "Campers completedChallenges array is free of duplicates", - "default": false - }, "isHonest": { "type": "boolean", "description": "Camper has signed academic honesty policy", @@ -208,35 +172,25 @@ "description": "Camper is information security and quality assurance certified", "default": false }, - "isChallengeMapMigrated": { - "type": "boolean", - "description": "Migrate completedChallenges array to challenge map", - "default": false - }, - "challengeMap": { - "type": "object", - "description": "A map by ID of all the user completed challenges", - "default": {} - }, "completedChallengeCount": { - "type": "number" + "type": "number", + "description": "generated per request, not held in db" + }, + "completedCertCount": { + "type": "number", + "description": "generated per request, not held in db" + }, + "completedProjectCount": { + "type": "number", + "description": "generated per request, not held in db" }, "completedChallenges": { "type": [ { "completedDate": "number", - "lastUpdated": "number", - "numOfAttempts": "number", "id": "string", - "name": "string", - "completedWith": "string", "solution": "string", - "githubLink": "string", - "verified": "boolean", - "challengeType": { - "type": "number", - "default": 0 - } + "challengeType": "number" } ], "default": [] @@ -249,9 +203,6 @@ "type": "number", "index": true }, - "tshirtVote": { - "type": "number" - }, "timezone": { "type": "string" }, diff --git a/seed/get-emails.js b/seed/get-emails.js index 290e37eeae..bc9cfd6525 100644 --- a/seed/get-emails.js +++ b/seed/get-emails.js @@ -13,7 +13,7 @@ MongoClient.connect(secrets.db, function(err, database) { {$match: { 'email': { $exists: true } } }, {$match: { 'email': { $ne: '' } } }, {$match: { 'email': { $ne: null } } }, - {$match: { 'sendMonthlyEmail': true } }, + {$match: { 'sendQuincyEmail': true } }, {$match: { 'email': { $not: /(test|fake)/i } } }, {$group: { '_id': 1, 'emails': {$addToSet: '$email' } } } ], function(err, results) { diff --git a/seed/loopbackMigration.js b/seed/loopbackMigration.js deleted file mode 100644 index 7b9329e881..0000000000 --- a/seed/loopbackMigration.js +++ /dev/null @@ -1,231 +0,0 @@ -/* eslint-disable no-process-exit */ -require('dotenv').load(); -var Rx = require('rx'), - uuid = require('uuid'), - assign = require('lodash/object/assign'), - mongodb = require('mongodb'), - secrets = require('../config/secrets'); - -const batchSize = 20; -var MongoClient = mongodb.MongoClient; -Rx.config.longStackSupport = true; - -var providers = [ - 'facebook', - 'twitter', - 'google', - 'github', - 'linkedin' -]; - -// create async console.logs -function debug() { - var args = [].slice.call(arguments); - process.nextTick(function() { - console.log.apply(console, args); - }); -} - -function createConnection(URI) { - return Rx.Observable.create(function(observer) { - debug('connecting to db'); - MongoClient.connect(URI, function(err, database) { - if (err) { - return observer.onError(err); - } - debug('db connected'); - observer.onNext(database); - observer.onCompleted(); - }); - }); -} - -function createQuery(db, collection, options, batchSize) { - return Rx.Observable.create(function(observer) { - var cursor = db.collection(collection).find({}, options); - cursor.batchSize(batchSize || 20); - // Cursor.each will yield all doc from a batch in the same tick, - // or schedule getting next batch on nextTick - debug('opening cursor for %s', collection); - cursor.each(function(err, doc) { - if (err) { - return observer.onError(err); - } - if (!doc) { - console.log('onCompleted'); - return observer.onCompleted(); - } - observer.onNext(doc); - }); - - return Rx.Disposable.create(function() { - debug('closing cursor for %s', collection); - cursor.close(); - }); - }); -} - -function getUserCount(db) { - return Rx.Observable.create(function(observer) { - var cursor = db.collection('users').count(function(err, count) { - if (err) { - return observer.onError(err); - } - observer.onNext(count); - observer.onCompleted(); - - return Rx.Disposable.create(function() { - debug('closing user count'); - cursor.close(); - }); - }); - }); -} - -function insertMany(db, collection, users, options) { - return Rx.Observable.create(function(observer) { - db.collection(collection).insertMany(users, options, function(err) { - if (err) { - return observer.onError(err); - } - observer.onNext(); - observer.onCompleted(); - }); - }); -} - -var count = 0; -// will supply our db object -var dbObservable = createConnection(secrets.db).replay(); - -var totalUser = dbObservable - .flatMap(function(db) { - return getUserCount(db); - }) - .shareReplay(); - -var users = dbObservable - .flatMap(function(db) { - // returns user document, n users per loop where n is the batchsize. - return createQuery(db, 'users', {}, batchSize); - }) - .map(function(user) { - // flatten user - assign(user, user.portfolio, user.profile); - if (!user.username) { - user.username = 'fcc' + uuid.v4().slice(0, 8); - } - if (user.github) { - user.isGithubCool = true; - } else { - user.isMigrationGrandfathered = true; - } - providers.forEach(function(provider) { - user[provider + 'id'] = user[provider]; - user[provider] = null; - }); - user.rand = Math.random(); - - return user; - }) - .shareReplay(); - -// batch them into arrays of twenty documents -var userSavesCount = users - .bufferWithCount(batchSize) - // get bd object ready for insert - .withLatestFrom(dbObservable, function(users, db) { - return { - users: users, - db: db - }; - }) - .flatMap(function(dats) { - // bulk insert into new collection for loopback - return insertMany(dats.db, 'user', dats.users, { w: 1 }); - }) - .flatMap(function() { - return totalUser; - }) - .doOnNext(function(totalUsers) { - count = count + batchSize; - debug('user progress %s', count / totalUsers * 100); - }) - // count how many times insert completes - .count(); - -// create User Identities -var userIdentityCount = users - .flatMap(function(user) { - var ids = providers - .map(function(provider) { - return { - provider: provider, - externalId: user[provider + 'id'], - userId: user._id || user.id - }; - }) - .filter(function(ident) { - return !!ident.externalId; - }); - - return Rx.Observable.from(ids); - }) - .bufferWithCount(batchSize) - .withLatestFrom(dbObservable, function(identities, db) { - return { - identities: identities, - db: db - }; - }) - .flatMap(function(dats) { - // bulk insert into new collection for loopback - return insertMany(dats.db, 'userIdentity', dats.identities, { w: 1 }); - }) - // count how many times insert completes - .count(); - -/* -var storyCount = dbObservable - .flatMap(function(db) { - return createQuery(db, 'stories', {}, batchSize); - }) - .bufferWithCount(batchSize) - .withLatestFrom(dbObservable, function(stories, db) { - return { - stories: stories, - db: db - }; - }) - .flatMap(function(dats) { - return insertMany(dats.db, 'story', dats.stories, { w: 1 }); - }) - .count(); - */ - -Rx.Observable.combineLatest( - userIdentityCount, - userSavesCount, - // storyCount, - function(userIdentCount, userCount) { - return { - userIdentCount: userIdentCount * batchSize, - userCount: userCount * batchSize - // storyCount: storyCount * batchSize - }; - }) - .subscribe( - function(countObj) { - console.log('next'); - count = countObj; - }, - function(err) { - console.error('an error occured', err, err.stack); - }, - function() { - console.log('finished with ', count); - process.exit(0); - } - ); - -dbObservable.connect(); diff --git a/server/boot/certificate.js b/server/boot/certificate.js index f28827af5b..5b1367401d 100644 --- a/server/boot/certificate.js +++ b/server/boot/certificate.js @@ -40,8 +40,14 @@ const renderCertifedEmail = loopback.template(path.join( 'certified.ejs' )); -function isCertified(ids, challengeMap = {}) { - return _.every(ids, ({ id }) => _.has(challengeMap, id)); +function isCertified(ids, completedChallenges = []) { + return _.every( + ids, + ({ id }) => _.find( + completedChallenges, + ({ id: completedId }) => completedId === id + ) + ); } const certIds = { @@ -73,17 +79,17 @@ const certViews = { }; const certText = { - [certTypes.frontEnd]: 'Legacy Front End certified', - [certTypes.backEnd]: 'Legacy Back End Certified', - [certTypes.dataVis]: 'Legacy Data Visualization Certified', - [certTypes.fullStack]: 'Legacy Full Stack Certified', - [certTypes.respWebDesign]: 'Responsive Web Design Certified', - [certTypes.frontEndLibs]: 'Front End Libraries Certified', + [certTypes.frontEnd]: 'Legacy Front End', + [certTypes.backEnd]: 'Legacy Back End', + [certTypes.dataVis]: 'Legacy Data Visualization', + [certTypes.fullStack]: 'Legacy Full Stack', + [certTypes.respWebDesign]: 'Responsive Web Design', + [certTypes.frontEndLibs]: 'Front End Libraries', [certTypes.jsAlgoDataStruct]: - 'JavaScript Algorithms and Data Structures Certified', - [certTypes.dataVis2018]: 'Data Visualization Certified', - [certTypes.apisMicroservices]: 'APIs and Microservices Certified', - [certTypes.infosecQa]: 'Information Security and Quality Assurance Certified' + 'JavaScript Algorithms and Data Structures', + [certTypes.dataVis2018]: 'Data Visualization', + [certTypes.apisMicroservices]: 'APIs and Microservices', + [certTypes.infosecQa]: 'Information Security and Quality Assurance' }; function getIdsForCert$(id, Challenge) { @@ -101,19 +107,6 @@ function getIdsForCert$(id, Challenge) { .shareReplay(); } -// sendCertifiedEmail( -// { -// email: String, -// username: String, -// isRespWebDesignCert: Boolean, -// isFrontEndLibsCert: Boolean, -// isJsAlgoDataStructCert: Boolean, -// isDataVisCert: Boolean, -// isApisMicroservicesCert: Boolean, -// isInfosecQaCert: Boolean -// }, -// send$: Observable -// ) => Observable function sendCertifiedEmail( { email, @@ -230,46 +223,49 @@ export default function certificate(app) { log(superBlock); let certType = superBlockCertTypeMap[superBlock]; log(certType); - return user.getChallengeMap$() + return user.getCompletedChallenges$() .flatMap(() => certTypeIds[certType]) .flatMap(challenge => { const { id, tests, - name, challengeType } = challenge; + const certName = certText[certType]; if (user[certType]) { - return Observable.just(alreadyClaimedMessage(name)); + return Observable.just(alreadyClaimedMessage(certName)); } - if (!user[certType] && !isCertified(tests, user.challengeMap)) { - return Observable.just(notCertifiedMessage(name)); + if (!user[certType] && !isCertified(tests, user.completedChallenges)) { + return Observable.just(notCertifiedMessage(certName)); } if (!user.name) { return Observable.just(noNameMessage); } const updateData = { - $set: { - [`challengeMap.${id}`]: { + $push: { + completedChallenges: { id, - name, completedDate: new Date(), challengeType - }, + } + }, + $set: { [certType]: true } }; // set here so sendCertifiedEmail works properly // not used otherwise user[certType] = true; - user.challengeMap[id] = { completedDate: new Date() }; + user.completedChallenges[ + user.completedChallenges.length - 1 + ] = { id, completedDate: new Date() }; return Observable.combineLatest( // update user data user.update$(updateData), // If user has committed to nonprofit, // this will complete their pledge completeCommitment$(user), - // sends notification email is user has all three certs + // sends notification email is user has all 6 certs // if not it noop sendCertifiedEmail(user, Email.send$), ({ count }, pledgeOrMessage) => ({ count, pledgeOrMessage }) @@ -280,7 +276,7 @@ export default function certificate(app) { log(pledgeOrMessage); } log(`${count} documents updated`); - return successMessage(user.username, name); + return successMessage(user.username, certName); } ); }) @@ -326,12 +322,12 @@ export default function certificate(app) { isHonest: true, username: true, name: true, - challengeMap: true + completedChallenges: true } ) .subscribe( user => { - const profile = `/${user.username}`; + const profile = `/portfolio/${user.username}`; if (!user) { req.flash( 'danger', @@ -352,7 +348,7 @@ export default function certificate(app) { } if (user.isCheater) { - return res.redirect(`/${user.username}`); + return res.redirect(profile); } if (user.isLocked) { @@ -378,8 +374,10 @@ export default function certificate(app) { } if (user[certType]) { - const { challengeMap = {} } = user; - const { completedDate = new Date() } = challengeMap[certId] || {}; + const { completedChallenges = {} } = user; + const { completedDate = new Date() } = _.find( + completedChallenges, ({ id }) => certId === id + ) || {}; return res.render( certViews[certType], @@ -392,7 +390,7 @@ export default function certificate(app) { } req.flash( 'danger', - `Looks like user ${username} is not ${certText[certType]}` + `Looks like user ${username} is not ${certText[certType]} certified` ); return res.redirect(profile); }, diff --git a/server/boot/challenge.js b/server/boot/challenge.js index 30a887fe86..e1d76294f2 100644 --- a/server/boot/challenge.js +++ b/server/boot/challenge.js @@ -20,39 +20,33 @@ function buildUserUpdate( timezone ) { let finalChallenge; - let numOfAttempts = 1; - const updateData = { $set: {} }; - const { timezone: userTimezone, challengeMap = {} } = user; + const updateData = { $set: {}, $push: {} }; + const { timezone: userTimezone, completedChallenges = [] } = user; - const oldChallenge = challengeMap[challengeId]; + const oldChallenge = _.find( + completedChallenges, + ({ id }) => challengeId === id + ); const alreadyCompleted = !!oldChallenge; if (alreadyCompleted) { - // add data from old challenge - if (oldChallenge.numOfAttempts) { - numOfAttempts = oldChallenge.numOfAttempts + 1; - } finalChallenge = { ...completedChallenge, - completedDate: oldChallenge.completedDate, - lastUpdated: completedChallenge.completedDate, - numOfAttempts + completedDate: oldChallenge.completedDate }; } else { updateData.$push = { - progressTimestamps: { - timestamp: Date.now(), - completedChallenge: challengeId - } + ...updateData.$push, + progressTimestamps: Date.now() }; finalChallenge = { - ...completedChallenge, - numOfAttempts + ...completedChallenge }; } - updateData.$set = { - [`challengeMap.${challengeId}`]: finalChallenge + updateData.$push = { + ...updateData.$push, + completedChallenges: finalChallenge }; if ( @@ -71,8 +65,7 @@ function buildUserUpdate( return { alreadyCompleted, updateData, - completedDate: finalChallenge.completedDate, - lastUpdated: finalChallenge.lastUpdated + completedDate: finalChallenge.completedDate }; } @@ -153,7 +146,7 @@ export default function(app) { } const user = req.user; - return user.getChallengeMap$() + return user.getCompletedChallenges$() .flatMap(() => { const completedDate = Date.now(); const { @@ -163,8 +156,7 @@ export default function(app) { const { alreadyCompleted, - updateData, - lastUpdated + updateData } = buildUserUpdate( user, id, @@ -180,8 +172,7 @@ export default function(app) { return res.json({ points, alreadyCompleted, - completedDate, - lastUpdated + completedDate }); } return res.sendStatus(200); @@ -204,15 +195,14 @@ export default function(app) { return res.sendStatus(403); } - return req.user.getChallengeMap$() + return req.user.getCompletedChallenges$() .flatMap(() => { const completedDate = Date.now(); const { id, solution, timezone } = req.body; const { alreadyCompleted, - updateData, - lastUpdated + updateData } = buildUserUpdate( req.user, id, @@ -230,8 +220,7 @@ export default function(app) { return res.json({ points, alreadyCompleted, - completedDate, - lastUpdated + completedDate }); } return res.sendStatus(200); @@ -280,12 +269,11 @@ export default function(app) { } - return user.getChallengeMap$() + return user.getCompletedChallenges$() .flatMap(() => { const { alreadyCompleted, - updateData, - lastUpdated + updateData } = buildUserUpdate(user, completedChallenge.id, completedChallenge); return user.update$(updateData) @@ -295,8 +283,7 @@ export default function(app) { return res.send({ alreadyCompleted, points: alreadyCompleted ? user.points : user.points + 1, - completedDate: completedChallenge.completedDate, - lastUpdated + completedDate: completedChallenge.completedDate }); } return res.status(200).send(true); @@ -329,12 +316,11 @@ export default function(app) { completedChallenge.completedDate = Date.now(); - return user.getChallengeMap$() + return user.getCompletedChallenges$() .flatMap(() => { const { alreadyCompleted, - updateData, - lastUpdated + updateData } = buildUserUpdate(user, completedChallenge.id, completedChallenge); return user.update$(updateData) @@ -344,8 +330,7 @@ export default function(app) { return res.send({ alreadyCompleted, points: alreadyCompleted ? user.points : user.points + 1, - completedDate: completedChallenge.completedDate, - lastUpdated + completedDate: completedChallenge.completedDate }); } return res.status(200).send(true); diff --git a/server/boot/settings.js b/server/boot/settings.js index 33e7953094..7322c63393 100644 --- a/server/boot/settings.js +++ b/server/boot/settings.js @@ -23,11 +23,11 @@ export default function settingsController(app) { ); }; - function refetchChallengeMap(req, res, next) { + function refetchCompletedChallenges(req, res, next) { const { user } = req; - return user.requestChallengeMap() + return user.requestCompletedChallenges() .subscribe( - challengeMap => res.json({ challengeMap }), + completedChallenges => res.json({ completedChallenges }), next ); } @@ -136,9 +136,9 @@ export default function settingsController(app) { } api.post( - '/refetch-user-challenge-map', + '/refetch-user-completed-challenges', ifNoUser401, - refetchChallengeMap + refetchCompletedChallenges ); api.post( '/update-flags', diff --git a/server/boot/user.js b/server/boot/user.js index 50f88bfcd6..6788e4e7e4 100644 --- a/server/boot/user.js +++ b/server/boot/user.js @@ -142,7 +142,7 @@ module.exports = function(app) { isBackEndCert: false, isDataVisCert: false, isFullStackCert: false, - challengeMap: {} + completedChallenges: [] }) .subscribe( () => { diff --git a/server/component-passport.js b/server/component-passport.js index d28e9bee5f..04b0f4135a 100644 --- a/server/component-passport.js +++ b/server/component-passport.js @@ -8,8 +8,7 @@ const passportOptions = { }; const fields = { - progressTimestamps: false, - completedChallenges: false + progressTimestamps: false }; function getCompletedCertCount(user) { @@ -44,7 +43,6 @@ PassportConfigurator.prototype.init = function passportInit(noSession) { this.userModel.findById(id, { fields }, (err, user) => { if (err || !user) { - user.challengeMap = {}; return done(err, user); } @@ -58,23 +56,21 @@ PassportConfigurator.prototype.init = function passportInit(noSession) { user.points = points; let completedChallengeCount = 0; let completedProjectCount = 0; - if ('challengeMap' in user) { - completedChallengeCount = Object.keys(user.challengeMap).length; - Object.keys(user.challengeMap) - .map(key => user.challengeMap[key]) - .forEach(item => { - if ( - 'challengeType' in item && - (item.challengeType === 3 || item.challengeType === 4) - ) { - completedProjectCount++; - } - }); + if ('completedChallenges' in user) { + completedChallengeCount = user.completedChallenges.length; + user.completedChallenges.forEach(item => { + if ( + 'challengeType' in item && + (item.challengeType === 3 || item.challengeType === 4) + ) { + completedProjectCount++; + } + }); } user.completedChallengeCount = completedChallengeCount; user.completedProjectCount = completedProjectCount; user.completedCertCount = getCompletedCertCount(user); - user.challengeMap = {}; + user.completedChallenges = []; return done(null, user); }); }); diff --git a/server/middleware.json b/server/middleware.json index 6afd88a9c4..a3a63e13fc 100644 --- a/server/middleware.json +++ b/server/middleware.json @@ -15,7 +15,7 @@ "initial": { "compression": {}, "morgan": { - "params": ":status :method :response-time ms - :url" + "params": ":date[iso] :status :method :response-time ms - :url" }, "cors": { "params": { @@ -54,7 +54,6 @@ "./middlewares/constant-headers": {}, "./middlewares/csp": {}, "./middlewares/jade-helpers": {}, - "./middlewares/migrate-completed-challenges": {}, "./middlewares/flash-cheaters": {}, "./middlewares/passport-login": {} }, diff --git a/server/middlewares/migrate-completed-challenges.js b/server/middlewares/migrate-completed-challenges.js deleted file mode 100644 index fa2952ddd1..0000000000 --- a/server/middlewares/migrate-completed-challenges.js +++ /dev/null @@ -1,127 +0,0 @@ -import { Observable, Scheduler } from 'rx'; -import { ObjectID } from 'mongodb'; -import debug from 'debug'; - -import idMap from '../utils/bad-id-map'; - -const log = debug('freecc:migrate'); -const challengeTypes = { - html: 0, - js: 1, - video: 2, - zipline: 3, - basejump: 4, - bonfire: 5, - hikes: 6, - step: 7, - waypoint: 0 -}; - -const challengeTypeReg = /^(waypoint|hike|zipline|basejump)/i; -const challengeTypeRegWithColon = - /^(bonfire|checkpoint|waypoint|hike|zipline|basejump):\s+/i; - -function updateName(challenge) { - if ( - challenge.name && - challenge.challengeType === 5 && - challengeTypeReg.test(challenge.name) - ) { - - challenge.name.replace(challengeTypeReg, match => { - // find the correct type - const type = challengeTypes[''.toLowerCase.call(match)]; - // if type found, replace current type - // - if (type) { - challenge.challengeType = type; - } - - return match; - }); - - } - - if (challenge.name) { - challenge.oldName = challenge.name; - challenge.name = challenge.name.replace(challengeTypeRegWithColon, ''); - } - return challenge; -} - -function updateId(challenge) { - if (idMap.hasOwnProperty(challenge.id)) { - challenge.id = idMap[challenge.id]; - } - - return challenge; -} - -// buildChallengeMap( -// userId: String, -// completedChallenges: Object[], -// User: User -// ) => Observable -function buildChallengeMap(userId, completedChallenges = [], User) { - return Observable.from( - completedChallenges, - null, - null, - Scheduler.default - ) - .map(challenge => { - return challenge && typeof challenge.toJSON === 'function' ? - challenge.toJSON() : - challenge; - }) - .map(updateId) - .filter(({ id, _id }) => ObjectID.isValid(id || _id)) - .map(updateName) - .reduce((challengeMap, challenge) => { - const id = challenge.id || challenge._id; - - challengeMap[id] = challenge; - return challengeMap; - }, {}) - .flatMap(challengeMap => { - const updateData = { - $set: { - challengeMap, - isChallengeMapMigrated: true - } - }; - return Observable.fromNodeCallback(User.updateAll, User)( - { id: userId }, - updateData, - { allowExtendedOperators: true } - ); - }); -} - -export default function migrateCompletedChallenges() { - return ({ user, app }, res, next) => { - const User = app.models.User; - if (!user || user.isChallengeMapMigrated) { - return next(); - } - const id = user.id.toString(); - return User.findOne$({ - where: { id }, - fields: { completedChallenges: true } - }) - .map(({ completedChallenges = [] } = {}) => completedChallenges) - .flatMap(completedChallenges => { - return buildChallengeMap( - id, - completedChallenges, - User - ); - }) - .subscribe( - count => log('documents update', count), - // errors go here - next, - next - ); - }; -} diff --git a/server/services/user.js b/server/services/user.js index cd1fc61098..5b9fc17d7f 100644 --- a/server/services/user.js +++ b/server/services/user.js @@ -18,10 +18,10 @@ export default function userServices() { cb) { const queryUser = req.user; const source = queryUser && Observable.forkJoin( - queryUser.getChallengeMap$(), + queryUser.getCompletedChallenges$(), queryUser.getPoints$(), - (challengeMap, progressTimestamps) => ({ - challengeMap, + (completedChallenges, progressTimestamps) => ({ + completedChallenges, progress: getProgress(progressTimestamps, queryUser.timezone) }) ); @@ -29,10 +29,10 @@ export default function userServices() { () => !queryUser, Observable.of({}), Observable.defer(() => source) - .map(({ challengeMap, progress }) => ({ + .map(({ completedChallenges, progress }) => ({ ...queryUser.toJSON(), ...progress, - challengeMap + completedChallenges })) .map( user => ({ @@ -41,7 +41,7 @@ export default function userServices() { [user.username]: { ..._.pick(user, userPropsForSession), isEmailVerified: !!user.emailVerified, - isGithub: !!user.githubURL, + isGithub: !!user.githubProfile, isLinkedIn: !!user.linkedIn, isTwitter: !!user.twitter, isWebsite: !!user.website, diff --git a/server/utils/auth.js b/server/utils/auth.js index 22c8cbb302..7c44a27029 100644 --- a/server/utils/auth.js +++ b/server/utils/auth.js @@ -27,17 +27,13 @@ export function createUserUpdatesFromProfile(provider, profile) { ) }; } -// using es6 argument destructing // createProfileAttributes(profile) => profileUpdate function createProfileAttributesFromGithub(profile) { const { - profileUrl: githubURL, + profileUrl: githubProfile, username, _json: { - id: githubId, avatar_url: picture, - email: githubEmail, - created_at: joinedGithubOn, blog: website, location, bio, @@ -49,14 +45,9 @@ function createProfileAttributesFromGithub(profile) { username: username.toLowerCase(), location, bio, - joinedGithubOn, website, - isGithubCool: true, picture, - githubId, - githubURL, - githubEmail, - githubProfile: githubURL + githubProfile }; } diff --git a/server/utils/publicUserProps.js b/server/utils/publicUserProps.js index 5016d960a0..4ff76b404c 100644 --- a/server/utils/publicUserProps.js +++ b/server/utils/publicUserProps.js @@ -10,8 +10,8 @@ import { export const publicUserProps = [ 'about', 'calendar', - 'challengeMap', - 'githubURL', + 'completedChallenges', + 'githubProfile', 'isApisMicroservicesCert', 'isBackEndCert', 'isCheater', @@ -20,7 +20,6 @@ export const publicUserProps = [ 'isFrontEndCert', 'isFullStackCert', 'isFrontEndLibsCert', - 'isGithubCool', 'isHonest', 'isInfosecQaCert', 'isJsAlgoDataStructCert', @@ -58,25 +57,12 @@ export function normaliseUserFields(user) { export function getProgress(progressTimestamps, timezone = 'EST') { const calendar = progressTimestamps - .map((objOrNum) => { - return typeof objOrNum === 'number' ? - objOrNum : - objOrNum.timestamp; - }) - .filter((timestamp) => { - return !!timestamp; - }) - .reduce((data, timeStamp) => { - data[Math.floor(timeStamp / 1000)] = 1; + .filter(Boolean) + .reduce((data, timestamp) => { + data[Math.floor(timestamp / 1000)] = 1; return data; }, {}); - const timestamps = progressTimestamps - .map(objOrNum => { - return typeof objOrNum === 'number' ? - objOrNum : - objOrNum.timestamp; - }); - const uniqueHours = prepUniqueDaysByHours(timestamps, timezone); + const uniqueHours = prepUniqueDaysByHours(progressTimestamps, timezone); const streak = { longest: calcLongestStreak(uniqueHours, timezone), current: calcCurrentStreak(uniqueHours, timezone) diff --git a/server/views/account/show.jade b/server/views/account/show.jade index 5511e0fce5..32187ff4b6 100644 --- a/server/views/account/show.jade +++ b/server/views/account/show.jade @@ -6,10 +6,6 @@ block content var challengeName = 'Profile View'; if (user && user.username === username) .row - if (!user.isGithubCool) - a.btn.btn-lg.btn-block.btn-github.btn-link-social(href='/link/github') - i.fa.fa-github - | Link my GitHub to enable my public profile .col-xs-12 a.btn.btn-lg.btn-block.btn-primary.btn-link-social(href='/settings') | Update my settings