Chore: Update User model (#17171)

* fix(logs): Remove console.log's

* chore(challengeMap): challengeMap -> completedChallenges

* chore(userModel): Update user model

* feat(userIDs): Add user ident fields

* chore(github): Remove more refs to github data
This commit is contained in:
Stuart Taylor
2018-05-15 14:56:26 +01:00
committed by mrugesh mohapatra
parent 156ea1af76
commit f916204ba5
23 changed files with 233 additions and 742 deletions

View File

@ -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,

View File

@ -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

View File

@ -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 (
<tr key={ id }>
<td>{ blockNameify(idToNameMap[id]) }</td>
@ -68,53 +58,12 @@ class Timeline extends PureComponent {
}
</time>
</td>
<td>
{
lastUpdated ?
<time dateTime={ format(lastUpdated, 'YYYY-MM-DDTHH:MM:SSZ') }>
{
format(lastUpdated, 'MMMM DD YYYY')
}
</time> :
''
}
</td>
<td>
{
files ?
<Button
block={ true }
bsStyle='primary'
onClick={ () => this.viewSolution(id) }
>
View&nbsp;Solution
</Button> :
''
}
</td>
</tr>
);
}
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 {
<FullWidthRow>
<h2 className='text-center'>Timeline</h2>
{
Object.keys(completedMap).length === 0 ?
completedMap.length === 0 ?
<p className='text-center'>
No challenges have been completed yet.&nbsp;
<Link to={ homeURL }>
@ -134,45 +83,21 @@ class Timeline extends PureComponent {
<tr>
<th>Challenge</th>
<th>First Completed</th>
<th>Last Changed</th>
<th />
</tr>
</thead>
<tbody>
{
reverse(
sortBy(
Object.keys(completedMap)
.filter(key => key in idToNameMap)
.map(key => completedMap[key]),
completedMap,
[ 'completedDate' ]
)
).filter(({id}) => id in idToNameMap)
)
.map(this.renderCompletion)
}
</tbody>
</Table>
}
{
id &&
<Modal
aria-labelledby='contained-modal-title'
onHide={this.closeSolution}
show={ solutionOpen }
>
<Modal.Header closeButton={ true }>
<Modal.Title id='contained-modal-title'>
{ `${username}'s Solution to ${blockNameify(idToNameMap[id])}` }
</Modal.Title>
</Modal.Header>
<Modal.Body>
<SolutionViewer files={ completedMap[id].files }/>
</Modal.Body>
<Modal.Footer>
<Button onClick={this.closeSolution}>Close</Button>
</Modal.Footer>
</Modal>
}
</FullWidthRow>
);
}

View File

@ -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

View File

@ -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,

View File

@ -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);

View File

@ -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
);

View File

@ -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 = {};

View File

@ -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;
});
};

View File

@ -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"
},

View File

@ -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) {

View File

@ -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();

View File

@ -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);
},

View File

@ -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);

View File

@ -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',

View File

@ -142,7 +142,7 @@ module.exports = function(app) {
isBackEndCert: false,
isDataVisCert: false,
isFullStackCert: false,
challengeMap: {}
completedChallenges: []
})
.subscribe(
() => {

View File

@ -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);
});
});

View File

@ -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": {}
},

View File

@ -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
);
};
}

View File

@ -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,

View File

@ -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
};
}

View File

@ -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)

View File

@ -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