Properly merge subscriptions from different places

This commit is contained in:
James Allen 2018-05-16 16:31:28 +01:00
parent 5b3fbe47db
commit b1f378208d
12 changed files with 389 additions and 42 deletions

View file

@ -9,7 +9,7 @@ COFFEE := node_modules/.bin/coffee $(COFFEE_OPTIONS)
GRUNT := node_modules/.bin/grunt
APP_COFFEE_FILES := $(shell find app/coffee -name '*.coffee')
FRONT_END_COFFEE_FILES := $(shell find public/coffee -name '*.coffee')
TEST_COFFEE_FILES := $(shell find test -name '*.coffee')
TEST_COFFEE_FILES := $(shell find test/*/coffee -name '*.coffee')
MODULE_MAIN_COFFEE_FILES := $(shell find modules -type f -wholename '*main/index.coffee')
MODULE_IDE_COFFEE_FILES := $(shell find modules -type f -wholename '*ide/index.coffee')
COFFEE_FILES := app.coffee $(APP_COFFEE_FILES) $(FRONT_END_COFFEE_FILES) $(TEST_COFFEE_FILES)

View file

@ -31,7 +31,7 @@ module.exports = ReferalAllocator =
assignBonus: (user_id, callback = (error) ->) ->
getBonusFeatures: (user_id, callback = (error) ->) ->
query = _id: user_id
User.findOne query, (error, user) ->
return callback(error) if error
@ -39,9 +39,7 @@ module.exports = ReferalAllocator =
logger.log user_id: user_id, refered_user_count: user.refered_user_count, "assigning bonus"
if user.refered_user_count? and user.refered_user_count > 0
newFeatures = ReferalAllocator._calculateFeatures(user)
if _.isEqual newFeatures, user.features
return callback()
User.update query, { $set: features: newFeatures }, callback
callback null, newFeatures
else
callback()

View file

@ -9,6 +9,7 @@ logger = require('logger-sharelatex')
GeoIpLookup = require("../../infrastructure/GeoIpLookup")
SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
UserGetter = require "../User/UserGetter"
SubscriptionUpdater = require './SubscriptionUpdater'
module.exports = SubscriptionController =
@ -237,3 +238,9 @@ module.exports = SubscriptionController =
return next(error) if error?
req.body = body
next()
refreshUserSubscription: (req, res, next) ->
{user_id} = req.params
SubscriptionUpdater.refreshSubscription user_id, (error) ->
return next(error) if error?
res.sendStatus 200

View file

@ -28,8 +28,8 @@ module.exports =
getSubscriptionByMemberIdAndId: (user_id, subscription_id, callback)->
Subscription.findOne {member_ids: user_id, _id:subscription_id}, {_id:1}, callback
getGroupSubscriptionMemberOf: (user_id, callback)->
Subscription.findOne {member_ids: user_id}, {_id:1, planCode:1}, callback
getGroupSubscriptionsMemberOf: (user_id, callback)->
Subscription.find {member_ids: user_id}, {_id:1, planCode:1}, callback
getGroupsWithEmailInvite: (email, callback) ->
Subscription.find { invited_emails: email }, callback

View file

@ -46,4 +46,6 @@ module.exports =
webRouter.get "/user/subscription/upgrade-annual", AuthenticationController.requireLogin(), SubscriptionController.renderUpgradeToAnnualPlanPage
webRouter.post "/user/subscription/upgrade-annual", AuthenticationController.requireLogin(), SubscriptionController.processUpgradeToAnnualPlan
# Currently used in acceptance tests only, as a way to trigger the syncing logic
publicApiRouter.post "/user/:user_id/subscription/sync", AuthenticationController.httpAuth, SubscriptionController.refreshUserSubscription

View file

@ -8,6 +8,7 @@ Settings = require("settings-sharelatex")
logger = require("logger-sharelatex")
ObjectId = require('mongoose').Types.ObjectId
ReferalAllocator = require("../Referal/ReferalAllocator")
V1SubscriptionManager = require("./V1SubscriptionManager")
oneMonthInSeconds = 60 * 60 * 24 * 30
@ -105,38 +106,66 @@ module.exports = SubscriptionUpdater =
_setUsersMinimumFeatures: (user_id, callback)->
jobs =
subscription: (cb)->
SubscriptionLocator.getUsersSubscription user_id, cb
groupSubscription: (cb)->
SubscriptionLocator.getGroupSubscriptionMemberOf user_id, cb
v1PlanCode: (cb) ->
Modules = require '../../infrastructure/Modules'
Modules.hooks.fire 'getV1PlanCode', user_id, (err, results) ->
cb(err, results?[0] || null)
individualFeatures: (cb)->
SubscriptionLocator.getUsersSubscription user_id, (err, sub)->
cb err, SubscriptionUpdater._subscriptionToFeatures(sub)
groupFeatures: (cb) ->
SubscriptionLocator.getGroupSubscriptionsMemberOf user_id, (err, subs) ->
cb err, (subs or []).map SubscriptionUpdater._subscriptionToFeatures
v1Features: (cb) ->
V1SubscriptionManager.getPlanCodeFromV1 user_id, (err, planCode) ->
cb err, SubscriptionUpdater._planCodeToFeatures(planCode)
bonusFeatures: (cb) ->
ReferalAllocator.getBonusFeatures user_id, cb
async.series jobs, (err, results)->
if err?
logger.err err:err, user_id:user_id,
"error getting subscription or group for _setUsersMinimumFeatures"
return callback(err)
{subscription, groupSubscription, v1PlanCode} = results
# Group Subscription
if groupSubscription? and groupSubscription.planCode?
logger.log user_id:user_id, "using group which user is memor of for features"
UserFeaturesUpdater.updateFeatures user_id, groupSubscription.planCode, callback
# Personal Subscription
else if subscription? and subscription.planCode? and subscription.planCode != Settings.defaultPlanCode
logger.log user_id:user_id, "using users subscription plan code for features"
UserFeaturesUpdater.updateFeatures user_id, subscription.planCode, callback
# V1 Subscription
else if v1PlanCode?
logger.log user_id: user_id, "using the V1 plan for features"
UserFeaturesUpdater.updateFeatures user_id, v1PlanCode, callback
# Default
else
logger.log user_id:user_id, "using default features for user with no subscription or group"
UserFeaturesUpdater.updateFeatures user_id, Settings.defaultPlanCode, (err)->
if err?
logger.err err:err, user_id:user_id, "Error setting minimum user feature"
return callback(err)
ReferalAllocator.assignBonus user_id, callback
{individualFeatures, groupFeatures, v1Features, bonusFeatures} = results
logger.log {user_id, individualFeatures, groupFeatures, v1Features, bonusFeatures}, 'merging user features'
featureSets = groupFeatures.concat [individualFeatures, v1Features, bonusFeatures]
features = _.reduce(featureSets, SubscriptionUpdater._mergeFeatures, Settings.defaultFeatures)
logger.log {user_id, features}, 'updating user features'
UserFeaturesUpdater.updateFeatures user_id, features, callback
_mergeFeatures: (featuresA, featuresB) ->
features = Object.assign({}, featuresA)
for key, value of featuresB
# Special merging logic for non-boolean features
if key == 'compileGroup'
if features['compileGroup'] == 'priority' or featuresB['compileGroup'] == 'priority'
features['compileGroup'] = 'priority'
else
features['compileGroup'] = 'standard'
else if key == 'collaborators'
if features['collaborators'] == -1 or featuresB['collaborators'] == -1
features['collaborators'] = -1
else
features['collaborators'] = Math.max(
features['collaborators'] or 0,
featuresB['collaborators'] or 0
)
else if key == 'compileTimeout'
features['compileTimeout'] = Math.max(
features['compileTimeout'] or 0,
featuresB['compileTimeout'] or 0
)
else
# Boolean keys, true is better
features[key] = features[key] or featuresB[key]
return features
_subscriptionToFeatures: (subscription) ->
SubscriptionUpdater._planCodeToFeatures(subscription?.planCode)
_planCodeToFeatures: (planCode) ->
if !planCode?
return {}
plan = PlansLocator.findLocalPlanInSettings planCode
if !plan?
return {}
else
return plan.features

View file

@ -4,12 +4,11 @@ PlansLocator = require("./PlansLocator")
module.exports =
updateFeatures: (user_id, plan_code, callback = (err, features)->)->
updateFeatures: (user_id, features, callback = (err, features)->)->
conditions = _id:user_id
update = {}
plan = PlansLocator.findLocalPlanInSettings(plan_code)
logger.log user_id:user_id, features:plan.features, plan_code:plan_code, "updating users features"
update["features.#{key}"] = value for key, value of plan.features
logger.log user_id:user_id, features:features, "updating users features"
update["features.#{key}"] = value for key, value of features
User.update conditions, update, (err)->
callback err, plan.features
callback err, features

View file

@ -0,0 +1,48 @@
UserGetter = require "../User/UserGetter"
request = require "request"
settings = require "settings-sharelatex"
logger = require "logger-sharelatex"
module.exports = V1SubscriptionManager =
# Returned planCode = 'v1_pro' | 'v1_pro_plus' | 'v1_student' | 'v1_free' | null
# For this to work, we need plans in settings with plan-codes:
# - 'v1_pro'
# - 'v1_pro_plus'
# - 'v1_student'
# - 'v1_free'
getPlanCodeFromV1: (userId, callback=(err, planCode)->) ->
logger.log {userId}, "[V1SubscriptionManager] fetching v1 plan for user"
UserGetter.getUser userId, {'overleaf.id': 1}, (err, user) ->
return callback(err) if err?
v1Id = user?.overleaf?.id
if !v1Id?
logger.log {userId}, "[V1SubscriptionManager] no v1 id found for user"
return callback(null, null)
V1SubscriptionManager._v1PlanRequest v1Id, (err, body) ->
return callback(err) if err?
planName = body.plan_name
logger.log {userId, planName, body}, "[V1SubscriptionManager] fetched v1 plan for user"
if planName in ['pro', 'pro_plus', 'student', 'free']
planName = "v1_#{planName}"
else
# Throw away 'anonymous', etc as being equivalent to null
planName = null
return callback(null, planName)
_v1PlanRequest: (v1Id, callback=(err, body)->) ->
request {
method: 'GET',
url: settings.apis.v1.url +
"/api/v1/sharelatex/users/#{v1Id}/plan_code"
auth:
user: settings.apis.v1.user
pass: settings.apis.v1.pass
sendImmediately: true
json: true,
timeout: 5 * 1000
}, (error, response, body) ->
return callback(error) if error?
if 200 <= response.statusCode < 300
return callback null, body
else
return callback new Error("non-success code from v1: #{response.statusCode}")

View file

@ -17,6 +17,7 @@ services:
PROJECT_HISTORY_ENABLED: 'true'
ENABLED_LINKED_FILE_TYPES: 'url'
LINKED_URL_PROXY: 'http://localhost:6543'
SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
depends_on:
- redis
- mongo

View file

@ -0,0 +1,151 @@
expect = require("chai").expect
async = require("async")
UserClient = require "./helpers/User"
request = require "./helpers/request"
settings = require "settings-sharelatex"
{ObjectId} = require("../../../app/js/infrastructure/mongojs")
Subscription = require("../../../app/js/models/Subscription").Subscription
User = require("../../../app/js/models/User").User
MockV1Api = require "./helpers/MockV1Api"
syncUserAndGetFeatures = (user, callback = (error, features) ->) ->
request {
method: 'POST',
url: "/user/#{user._id}/subscription/sync",
auth:
user: 'sharelatex'
pass: 'password'
sendImmediately: true
}, (error, response, body) ->
throw error if error?
expect(response.statusCode).to.equal 200
User.findById user._id, (error, user) ->
return callback(error) if error?
features = user.toObject().features
delete features.$init # mongoose internals
return callback null, features
describe "Subscriptions", ->
beforeEach (done) ->
@user = new UserClient()
@user.ensureUserExists (error) ->
throw error if error?
done()
describe "when user has no subscriptions", ->
it "should set their features to the basic set", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
expect(features).to.deep.equal(settings.defaultFeatures)
done()
describe "when the user has an individual subscription", ->
beforeEach ->
Subscription.create {
admin_id: @user._id
planCode: 'collaborator'
customAccount: true
} # returns a promise
it "should set their features to the upgraded set", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
plan = settings.plans.find (plan) -> plan.planCode == 'collaborator'
expect(features).to.deep.equal(plan.features)
done()
describe "when the user is in a group subscription", ->
beforeEach ->
Subscription.create {
admin_id: ObjectId()
member_ids: [@user._id]
groupAccount: true
planCode: 'collaborator'
customAccount: true
} # returns a promise
it "should set their features to the upgraded set", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
plan = settings.plans.find (plan) -> plan.planCode == 'collaborator'
expect(features).to.deep.equal(plan.features)
done()
describe "when the user has bonus features", ->
beforeEach ->
User.update {
_id: @user._id
}, {
refered_user_count: 10
} # returns a promise
it "should set their features to the bonus set", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
expect(features).to.deep.equal(Object.assign(
{}, settings.defaultFeatures, settings.bonus_features[9]
))
done()
describe "when the user has a v1 plan", ->
beforeEach ->
MockV1Api.setUser 42, plan_name: 'free'
User.update {
_id: @user._id
}, {
overleaf:
id: 42
} # returns a promise
it "should set their features to the v1 plan", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
plan = settings.plans.find (plan) -> plan.planCode == 'v1_free'
expect(features).to.deep.equal(plan.features)
done()
describe "when the user has a v1 plan and bonus features", ->
beforeEach ->
MockV1Api.setUser 42, plan_name: 'free'
User.update {
_id: @user._id
}, {
overleaf:
id: 42
refered_user_count: 10
} # returns a promise
it "should set their features to the best of the v1 plan and bonus features", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
v1plan = settings.plans.find (plan) -> plan.planCode == 'v1_free'
expectedFeatures = Object.assign(
{}, v1plan.features, settings.bonus_features[9]
)
expect(features).to.deep.equal(expectedFeatures)
done()
describe "when the user has a group and personal subscription", ->
beforeEach (done) ->
Subscription.create {
admin_id: @user._id
planCode: 'professional'
customAccount: true
}, (error) =>
throw error if error?
Subscription.create {
admin_id: ObjectId()
member_ids: [@user._id]
groupAccount: true
planCode: 'collaborator'
customAccount: true
}, done
return
it "should set their features to the best set", (done) ->
syncUserAndGetFeatures @user, (error, features) =>
throw error if error?
plan = settings.plans.find (plan) -> plan.planCode == 'professional'
expect(features).to.deep.equal(plan.features)
done()

View file

@ -5,6 +5,10 @@ bodyParser = require('body-parser')
app.use(bodyParser.json())
module.exports = MockV1Api =
users: { }
setUser: (id, user) ->
@users[id] = user
exportId: null
@ -20,6 +24,13 @@ module.exports = MockV1Api =
@exportParams = null
run: () ->
app.get "/api/v1/sharelatex/users/:ol_user_id/plan_code", (req, res, next) =>
user = @users[req.params.ol_user_id]
if user
res.json user
else
res.sendStatus 404
app.post "/api/v1/sharelatex/exports", (req, res, next) =>
#{project, version, pathname}
@exportParams = Object.assign({}, req.body)
@ -28,7 +39,7 @@ module.exports = MockV1Api =
app.listen 5000, (error) ->
throw error if error?
.on "error", (error) ->
console.error "error starting MockOverleafAPI:", error.message
console.error "error starting MockV1Api:", error.message
process.exit(1)
MockV1Api.run()

View file

@ -0,0 +1,101 @@
module.exports =
apis:
v1:
url: "http://localhost:5000"
user: 'overleaf'
pass: 'password'
enableSubscriptions: true
features: features =
v1_free:
collaborators: 1
dropbox: false
versioning: false
github: true
templates: false
references: false
referencesSearch: false
mendeley: true
compileTimeout: 60
compileGroup: "standard"
trackChanges: false
personal:
collaborators: 1
dropbox: false
versioning: false
github: false
templates: false
references: false
referencesSearch: false
mendeley: false
compileTimeout: 60
compileGroup: "standard"
trackChanges: false
collaborator:
collaborators: 10
dropbox: true
versioning: true
github: true
templates: true
references: true
referencesSearch: true
mendeley: true
compileTimeout: 180
compileGroup: "priority"
trackChanges: true
professional:
collaborators: -1
dropbox: true
versioning: true
github: true
templates: true
references: true
referencesSearch: true
mendeley: true
compileTimeout: 180
compileGroup: "priority"
trackChanges: true
defaultFeatures: features.personal
defaultPlanCode: 'personal'
plans: plans = [{
planCode: "v1_free"
name: "V1 Free"
price: 0
features: features.v1_free
},{
planCode: "personal"
name: "Personal"
price: 0
features: features.personal
},{
planCode: "collaborator"
name: "Collaborator"
price: 1500
features: features.collaborator
},{
planCode: "professional"
name: "Professional"
price: 3000
features: features.professional
}]
bonus_features:
1:
collaborators: 2
dropbox: false
versioning: false
3:
collaborators: 4
dropbox: false
versioning: false
6:
collaborators: 4
dropbox: true
versioning: true
9:
collaborators: -1
dropbox: true
versioning: true