diff --git a/config.json.example b/config.json.example index 57464ef94a..81cd66622b 100644 --- a/config.json.example +++ b/config.json.example @@ -39,6 +39,7 @@ "NEW_RELIC_API_KEY":"NEW_RELIC_API_KEY", "GA_ID": "GA_ID", "AMPLITUDE_KEY": "AMPLITUDE_KEY", + "AMPLITUDE_SECRET": "AMPLITUDE_SECRET", "AMAZON_PAYMENTS": { "SELLER_ID": "SELLER_ID", "CLIENT_ID": "CLIENT_ID", diff --git a/database_reports/20181001_backtoschool_challenge.js b/database_reports/20181001_backtoschool_challenge.js new file mode 100644 index 0000000000..ec22d1de8d --- /dev/null +++ b/database_reports/20181001_backtoschool_challenge.js @@ -0,0 +1,48 @@ +import monk from 'monk'; +import nconf from 'nconf'; + +/* + * Output data on users who completed all the To-Do tasks in the 2018 Back-to-School Challenge. + * User ID,Profile Name + */ +const CONNECTION_STRING = nconf.get('MIGRATION_CONNECT_STRING'); +const CHALLENGE_ID = '0acb1d56-1660-41a4-af80-9259f080b62b'; + +let dbUsers = monk(CONNECTION_STRING).get('users', { castIds: false }); +let dbTasks = monk(CONNECTION_STRING).get('tasks', { castIds: false }); + +function usersReport() { + console.info('User ID,Profile Name'); + let userCount = 0; + + dbUsers.find( + {challenges: CHALLENGE_ID}, + {fields: + {_id: 1, 'profile.name': 1} + }, + ).each((user, {close, pause, resume}) => { + pause(); + userCount++; + let completedTodos = 0; + return dbTasks.find( + { + userId: user._id, + 'challenge.id': CHALLENGE_ID, + type: 'todo', + }, + {fields: {completed: 1}} + ).each((task) => { + if (task.completed) completedTodos++; + }).then(() => { + if (completedTodos >= 7) { + console.info(`${user._id},${user.profile.name}`); + } + resume(); + }); + }).then(() => { + console.info(`${userCount} users reviewed`); + return process.exit(0); + }); +} + +module.exports = usersReport; diff --git a/migrations/migration-runner.js b/migrations/migration-runner.js index cc8d33e9b8..e9b62d5c2e 100644 --- a/migrations/migration-runner.js +++ b/migrations/migration-runner.js @@ -18,4 +18,11 @@ setUpServer(); // Replace this with your migration const processUsers = require('./users/takeThis.js'); -processUsers(); +processUsers() + .then(function success () { + process.exitCode = 0; + }) + .catch(function failure (err) { + console.log(err); + process.exitCode = 1; + }); diff --git a/migrations/users/20181002_username_email.js b/migrations/users/20181002_username_email.js new file mode 100644 index 0000000000..908d83682e --- /dev/null +++ b/migrations/users/20181002_username_email.js @@ -0,0 +1,107 @@ +const MIGRATION_NAME = '20181003_username_email.js'; +let authorName = 'Sabe'; // in case script author needs to know when their ... +let authorUuid = '7f14ed62-5408-4e1b-be83-ada62d504931'; // ... own data is done + +/* + * Send emails to eligible users announcing upcoming username changes + */ + +import monk from 'monk'; +import nconf from 'nconf'; +import { sendTxn } from '../../website/server/libs/email'; +const CONNECTION_STRING = nconf.get('MIGRATION_CONNECT_STRING'); +let dbUsers = monk(CONNECTION_STRING).get('users', { castIds: false }); + +function processUsers (lastId) { + // specify a query to limit the affected users (empty for all users): + let query = { + migration: {$ne: MIGRATION_NAME}, + 'auth.timestamps.loggedin': {$gt: new Date('2018-04-01')}, + }; + + if (lastId) { + query._id = { + $gt: lastId, + }; + } + + dbUsers.find(query, { + sort: {_id: 1}, + limit: 100, + fields: [ + '_id', + 'auth', + 'preferences', + 'profile', + ], // specify fields we are interested in to limit retrieved data (empty if we're not reading data): + }) + .then(updateUsers) + .catch((err) => { + console.log(err); + return exiting(1, `ERROR! ${ err}`); + }); +} + +let progressCount = 1000; +let count = 0; + +function updateUsers (users) { + if (!users || users.length === 0) { + console.warn('All appropriate users found and modified.'); + displayData(); + return; + } + + let userPromises = users.map(updateUser); + let lastUser = users[users.length - 1]; + + return Promise.all(userPromises) + .then(() => delay(7000)) + .then(() => { + processUsers(lastUser._id); + }); +} + +function updateUser (user) { + count++; + + dbUsers.update({_id: user._id}, {$set: {migration: MIGRATION_NAME}}); + + sendTxn( + user, + 'username-change', + [{name: 'UNSUB_EMAIL_TYPE_URL', content: '/user/settings/notifications?unsubFrom=importantAnnouncements'}, + {name: 'LOGIN_NAME', content: user.auth.local.username}] + ); + + if (count % progressCount === 0) console.warn(`${count} ${user._id}`); + if (user._id === authorUuid) console.warn(`${authorName} processed`); +} + +function displayData () { + console.warn(`\n${count} users processed\n`); + return exiting(0); +} + +function delay (t, v) { + return new Promise(function batchPause (resolve) { + setTimeout(resolve.bind(null, v), t); + }); +} + +function exiting (code, msg) { + code = code || 0; // 0 = success + if (code && !msg) { + msg = 'ERROR!'; + } + if (msg) { + if (code) { + console.error(msg); + } else { + console.log(msg); + } + } + process.exit(code); +} + +module.exports = processUsers; diff --git a/scripts/gdpr-delete-users.js b/scripts/gdpr-delete-users.js new file mode 100644 index 0000000000..59dd5f5cc2 --- /dev/null +++ b/scripts/gdpr-delete-users.js @@ -0,0 +1,82 @@ +/* eslint-disable no-console */ +import axios from 'axios'; +import { model as User } from '../website/server/models/user'; +import nconf from 'nconf'; + +const AMPLITUDE_KEY = nconf.get('AMPLITUDE_KEY'); +const AMPLITUDE_SECRET = nconf.get('AMPLITUDE_SECRET'); +const BASE_URL = nconf.get('BASE_URL'); + +async function _deleteAmplitudeData (userId, email) { + const response = await axios.post( + 'https://amplitude.com/api/2/deletions/users', + { + user_ids: userId, // eslint-disable-line camelcase + requester: email, + }, + { + auth: { + username: AMPLITUDE_KEY, + password: AMPLITUDE_SECRET, + }, + } + ); + + console.log(`${response.status} ${response.statusText}`); +} + +async function _deleteHabiticaData (user) { + await User.update( + {_id: user._id}, + {$set: { + 'auth.local.passwordHashMethod': 'bcrypt', + 'auth.local.hashed_password': '$2a$10$QDnNh1j1yMPnTXDEOV38xOePEWFd4X8DSYwAM8XTmqmacG5X0DKjW', + }} + ); + const response = await axios.delete( + `${BASE_URL}/api/v3/user`, + { + data: { + password: 'test', + }, + headers: { + 'x-api-user': user._id, + 'x-api-key': user.apiToken, + }, + } + ); + + console.log(`${response.status} ${response.statusText}`); + if (response.status === 200) console.log(`${user._id} removed. Last login: ${user.auth.timestamps.loggedin}`); +} + +async function _processEmailAddress (email) { + const emailRegex = new RegExp(`^${email}`, 'i'); + const users = await User.find({ + $or: [ + {'auth.local.email': emailRegex}, + {'auth.facebook.emails.value': emailRegex}, + {'auth.google.emails.value': emailRegex}, + ]}, + { + _id: 1, + apiToken: 1, + auth: 1, + }).exec(); + + if (users.length < 1) { + console.warn(`No users found with email address ${email}`); + } else { + for (const user of users) { + await _deleteAmplitudeData(user._id, email); // eslint-disable-line no-await-in-loop + await _deleteHabiticaData(user); // eslint-disable-line no-await-in-loop + } + } +} + +function deleteUserData (emails) { + const emailPromises = emails.map(_processEmailAddress); + return Promise.all(emailPromises); +} + +module.exports = deleteUserData;