diff --git a/services/web/app/src/Features/Subscription/UserFeaturesUpdater.js b/services/web/app/src/Features/Subscription/UserFeaturesUpdater.js index cc72632b8c..f041b06137 100644 --- a/services/web/app/src/Features/Subscription/UserFeaturesUpdater.js +++ b/services/web/app/src/Features/Subscription/UserFeaturesUpdater.js @@ -39,6 +39,14 @@ module.exports = { return callback(err, featuresChanged) }) }, + + createFeaturesOverride(userId, featuresOverride, callback) { + User.updateOne( + { _id: userId }, + { $push: { featuresOverrides: featuresOverride } }, + callback + ) + }, } module.exports.promises = promisifyAll(module.exports, { diff --git a/services/web/scripts/add_feature_override.js b/services/web/scripts/add_feature_override.js new file mode 100644 index 0000000000..a14b852a1b --- /dev/null +++ b/services/web/scripts/add_feature_override.js @@ -0,0 +1,178 @@ +// Script to add feature overrides +// +// A feature override is appended to the user's featuresOverride list if they do +// not already have the feature. The features are refreshed after adding the +// override. +// +// If the script detects that the user would have the feature just by refreshing +// then it skips adding the override and just refreshes the users features -- +// this is to minimise the creation of unnecessary overrides. +// +// Usage: +// +// $ node scripts/add_feature_override.js --commit --note 'text description' --expires 2022-01-01 --override JSONFILE --ids IDFILE +// +// --commit do the update, remove this option for dry-run testing +// --note text description [optional] +// --expires expiry date for override [optional] +// --skip-existing don't create the override for users who already have the feature (e.g. via a subscription) +// +// IDFILE: file containing list of user ids, one per line +// JSONFILE: file containing JSON of the desired feature overrides e.g. {"symbolPalette": true} +// +// The feature override is specified with JSON to allow types to be set as string/number/boolean. +// It is contained in a file to avoid any issues with shell quoting. + +const minimist = require('minimist') +const fs = require('fs') +const { ObjectId, waitForDb } = require('../app/src/infrastructure/mongodb') +const pLimit = require('p-limit') +const FeaturesUpdater = require('../app/src/Features/Subscription/FeaturesUpdater') +const FeaturesHelper = require('../app/src/Features/Subscription/FeaturesHelper') +const UserFeaturesUpdater = require('../app/src/Features/Subscription/UserFeaturesUpdater') +const UserGetter = require('../app/src/Features/User/UserGetter') + +const processLogger = { + failed: [], + success: [], + skipped: [], + printSummary: () => { + console.log( + { + success: processLogger.success, + failed: processLogger.failed, + skipped: processLogger.skipped, + }, + `\nDONE. ${processLogger.success.length} successful. ${processLogger.skipped.length} skipped. ${processLogger.failed.length} failed to update.` + ) + }, +} + +function _validateUserIdList(userIds) { + userIds.forEach(userId => { + if (!ObjectId.isValid(userId)) + throw new Error(`user ID not valid: ${userId}`) + }) +} + +async function _handleUser(userId) { + const user = await UserGetter.promises.getUser(userId, { + features: 1, + featuresOverrides: 1, + }) + if (!user) { + console.log(userId, 'does not exist, failed') + processLogger.failed.push(userId) + return + } + const desiredFeatures = OVERRIDE.features + // Does the user have the requested features already? + if ( + SKIP_EXISTING && + FeaturesHelper.isFeatureSetBetter(user.features, desiredFeatures) + ) { + console.log( + userId, + `already has ${JSON.stringify(desiredFeatures)}, skipping` + ) + processLogger.skipped.push(userId) + return + } + // Would the user have the requested feature if the features were refreshed? + const freshFeatures = await FeaturesUpdater.promises.computeFeatures(userId) + if ( + SKIP_EXISTING && + FeaturesHelper.isFeatureSetBetter(freshFeatures, desiredFeatures) + ) { + console.log( + userId, + `would have ${JSON.stringify( + desiredFeatures + )} if refreshed, skipping override` + ) + } else { + // create the override (if not in dry-run mode) + if (COMMIT) { + await UserFeaturesUpdater.promises.createFeaturesOverride( + userId, + OVERRIDE + ) + } + } + + if (!COMMIT) { + // not saving features; nothing else to do + return + } + const refreshResult = await FeaturesUpdater.promises.refreshFeatures( + userId, + 'add-feature-override-script' + ) + const featureSetIncludesNewFeatures = FeaturesHelper.isFeatureSetBetter( + refreshResult.features, + desiredFeatures + ) + if (featureSetIncludesNewFeatures) { + // features added successfully + processLogger.success.push(userId) + } else { + console.log('FEATURE NOT ADDED', refreshResult) + processLogger.failed.push(userId) + } +} + +const argv = minimist(process.argv.slice(2)) +const CONCURRENCY = argv.async ? argv.async : 10 +const overridesFilename = argv.override +const expires = argv.expires +const note = argv.note +const SKIP_EXISTING = argv['skip-existing'] || false +const COMMIT = argv.commit !== undefined +if (!COMMIT) { + console.warn('Doing dry run without --commit') +} + +const idsFilename = argv.ids +if (!idsFilename) throw new Error('missing ids list filename') + +const usersFile = fs.readFileSync(idsFilename, 'utf8') +const userIds = usersFile + .trim() + .split('\n') + .map(id => id.trim()) + +const overridesFile = fs.readFileSync(overridesFilename, 'utf8') +const features = JSON.parse(overridesFile) +const OVERRIDE = { features } +if (note) { + OVERRIDE.note = note +} +if (expires) { + OVERRIDE.expiresAt = new Date(expires) +} + +async function processUsers(userIds) { + console.log('---Starting add feature override script---') + + console.log('Will update users to have', OVERRIDE) + console.log( + SKIP_EXISTING + ? 'Users with this feature already will be skipped' + : 'Every user in file will get feature override' + ) + + await waitForDb() + + _validateUserIdList(userIds) + console.log(`---Starting to process ${userIds.length} users---`) + + const limit = pLimit(CONCURRENCY) + await Promise.all( + userIds.map(userId => limit(() => _handleUser(ObjectId(userId)))) + ) + + processLogger.printSummary() + process.exit() +} + +processUsers(userIds)