From c07c44f3660bbb925921fa5ba86617b227ca1173 Mon Sep 17 00:00:00 2001 From: Simon Detheridge Date: Thu, 16 May 2019 11:11:42 +0100 Subject: [PATCH] Merge pull request #1644 from overleaf/spd-reconcile-accounts Add account-reconciliation script GitOrigin-RevId: e20a8b9531126d91baaa9da5c77ab6e971b43e67 --- .../coffee/Features/Email/EmailBuilder.coffee | 64 +++++++++++++++++++ .../Features/Project/ProjectDeleter.coffee | 8 +-- .../Project/ProjectDetailsHandler.coffee | 9 ++- .../coffee/Features/User/UserDeleter.coffee | 6 +- .../User/UserEmailsConfirmationHandler.coffee | 5 ++ services/web/app/coffee/models/User.coffee | 1 + .../coffee/Project/ProjectDeleterTests.coffee | 32 +++++----- .../Project/ProjectDetailsHandlerTests.coffee | 4 ++ .../unit/coffee/User/UserDeleterTests.coffee | 26 ++++---- 9 files changed, 118 insertions(+), 37 deletions(-) diff --git a/services/web/app/coffee/Features/Email/EmailBuilder.coffee b/services/web/app/coffee/Features/Email/EmailBuilder.coffee index 85a7b9f86b..bf2e42c09a 100644 --- a/services/web/app/coffee/Features/Email/EmailBuilder.coffee +++ b/services/web/app/coffee/Features/Email/EmailBuilder.coffee @@ -150,6 +150,70 @@ templates.testEmail = CTAEmailTemplate({ ctaURL: () -> settings.siteUrl }) +templates.projectsTransferredFromSharelatex = CTAEmailTemplate({ + subject: () -> "ShareLaTeX projects transferred to your Overleaf account" + title: () -> "ShareLaTeX projects transferred to your Overleaf account" + message: (opts) -> """ +We are writing with important information about your Overleaf and ShareLaTeX accounts. + +As part of our ongoing work to [integrate Overleaf and ShareLaTeX](https://www.overleaf.com/blog/518-exciting-news-sharelatex-is-joining-overleaf), +we found a ShareLaTeX account with the email address #{opts.to} that matches your Overleaf account. + +We have now transferred the projects from this ShareLaTeX account into your Overleaf account, so you may notice some new +projects on your Overleaf projects page. + +When you next log in, you may be prompted to reconfirm your email address in order to regain access to your account. +If you have any questions, please contact our support team by reply. +""" + ctaText: () -> "Log in to #{ settings.appName }" + ctaURL: () -> settings.siteUrl + "login" +}) + +templates.emailAddressPoachedEmail = CTAEmailTemplate({ + subject: () -> "One of your email addresses has been moved to another #{ settings.appName } account" + title: () -> "One of your email addresses has been moved to another #{ settings.appName } account" + message: (opts) -> + message = """ +We are writing with important information about your Overleaf account. + +You added the email address #{opts.poached} to your #{opts.to} Overleaf account as a secondary (or affiliation) +email address, but we have had to remove it. + +This is because your #{opts.poached} email address was also in use as the primary email address for an older Overleaf +account from before our [integration with ShareLaTeX to create Overleaf v2](https://www.overleaf.com/blog/518-exciting-news-sharelatex-is-joining-overleaf). + +### What do I need to do? + +You now have two Overleaf accounts, one under #{opts.poached} and one under #{opts.to}. + +You may wish to log in to Overleaf as #{opts.poached} to check whether you have projects there that you would like to +keep. If you are not sure of the password, you can send yourself a password reset email to #{opts.poached}, via +https://www.overleaf.com/user/password/reset + +Once you have downloaded your projects, you may wish to delete your +#{opts.poached} Overleaf account, which you can do from your account settings. You will then be able to add +#{opts.poached} as a secondary email address on your #{opts.to} account again. + + +""" + if opts.proFeatures + message += """ +Because your #{opts.poached} email address was an institutional affiliation through which you had Pro features. Your Pro +features have been transferred to your #{opts.poached} account. If you would like to transfer them back to your +#{opts.to} account, you will need to delete the #{opts.poached} account and re-add it as a secondary email address, +as described above. + + +""" + + message += """ +If you have any questions, you can contact our support team by reply. +""" + return message + ctaText: () -> "Log in to #{ settings.appName }" + ctaURL: () -> settings.siteUrl + "login" +}) + module.exports = templates: templates CTAEmailTemplate: CTAEmailTemplate diff --git a/services/web/app/coffee/Features/Project/ProjectDeleter.coffee b/services/web/app/coffee/Features/Project/ProjectDeleter.coffee index 7a7bbfa984..40ccda2da2 100644 --- a/services/web/app/coffee/Features/Project/ProjectDeleter.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDeleter.coffee @@ -28,9 +28,9 @@ module.exports = ProjectDeleter = logger.log {user_id}, "deleting users projects" ProjectDeleter._deleteUsersProjectWithMethod user_id, ProjectDeleter.deleteProject, callback - softDeleteUsersProjects: (user_id, callback)-> + softDeleteUsersProjectsForMigration: (user_id, callback)-> logger.log {user_id}, "soft-deleting users projects" - ProjectDeleter._deleteUsersProjectWithMethod user_id, ProjectDeleter.softDeleteProject, callback + ProjectDeleter._deleteUsersProjectWithMethod user_id, ProjectDeleter.softDeleteProjectForMigration, callback _deleteUsersProjectWithMethod: (user_id, deleteMethod, callback) -> Project.find {owner_ref: user_id}, (error, projects) -> @@ -44,7 +44,7 @@ module.exports = ProjectDeleter = CollaboratorsHandler.removeUserFromAllProjets user_id, callback ) - softDeleteProject: (project_id, callback) -> + softDeleteProjectForMigration: (project_id, callback) -> logger.log project_id: project_id, "soft-deleting project" async.waterfall [ (cb) -> @@ -52,7 +52,7 @@ module.exports = ProjectDeleter = (project, cb) -> return callback(new Errors.NotFoundError("project not found")) unless project? project.deletedAt = new Date() - db.deletedProjects.insert project, (err) -> cb(err) + db.projectsDeletedByMigration.insert project, (err) -> cb(err) (cb) -> ProjectDeleter.deleteProject project_id, cb ], callback diff --git a/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee b/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee index b439707cd8..3b43049576 100644 --- a/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee @@ -47,8 +47,11 @@ module.exports = ProjectDetailsHandler = logger.err err:err, "something went wrong setting project description" callback(err) - transferOwnership: (project_id, user_id, callback)-> - ProjectGetter.getProject project_id, {owner_ref: true, name: true}, (err, project)-> + transferOwnership: (project_id, user_id, suffix = "", callback)-> + if typeof suffix is 'function' + callback = suffix + suffix = '' + ProjectGetter.getProject project_id, {owner_ref: true}, (err, project)-> return callback(err) if err? return callback(new Errors.NotFoundError("project not found")) unless project? return callback() if project.owner_ref == user_id @@ -57,7 +60,7 @@ module.exports = ProjectDetailsHandler = return callback(err) if err? return callback(new Errors.NotFoundError("user not found")) unless user? - ProjectDetailsHandler.generateUniqueName user_id, project.name, (err, name) -> + ProjectDetailsHandler.generateUniqueName user_id, project.name + suffix, (err, name) -> return callback(err) if err? Project.update {_id: project_id}, diff --git a/services/web/app/coffee/Features/User/UserDeleter.coffee b/services/web/app/coffee/Features/User/UserDeleter.coffee index 56dcb5463a..1dd309eac7 100644 --- a/services/web/app/coffee/Features/User/UserDeleter.coffee +++ b/services/web/app/coffee/Features/User/UserDeleter.coffee @@ -12,7 +12,7 @@ Errors = require("../Errors/Errors") module.exports = UserDeleter = - softDeleteUser: (user_id, callback = (err)->)-> + softDeleteUserForMigration: (user_id, callback = (err)->)-> if !user_id? logger.err "user_id is null when trying to delete user" return callback(new Error("no user_id")) @@ -23,10 +23,10 @@ module.exports = UserDeleter = (cb) -> UserDeleter._cleanupUser user, cb (cb) -> - ProjectDeleter.softDeleteUsersProjects user._id, cb + ProjectDeleter.softDeleteUsersProjectsForMigration user._id, cb (cb) -> user.deletedAt = new Date() - db.deletedUsers.insert user, cb + db.usersDeletedByMigration.insert user, cb (cb) -> user.remove cb ], callback) diff --git a/services/web/app/coffee/Features/User/UserEmailsConfirmationHandler.coffee b/services/web/app/coffee/Features/User/UserEmailsConfirmationHandler.coffee index ad46922a92..d353165e4d 100644 --- a/services/web/app/coffee/Features/User/UserEmailsConfirmationHandler.coffee +++ b/services/web/app/coffee/Features/User/UserEmailsConfirmationHandler.coffee @@ -14,6 +14,11 @@ module.exports = UserEmailsConfirmationHandler = if arguments.length == 3 callback = emailTemplate emailTemplate = 'confirmEmail' + + # when force-migrating accounts to v2 from v1, we don't want to send confirmation messages - + # setting this env var allows us to turn this behaviour off + return callback(null) if process.env['SHARELATEX_NO_CONFIRMATION_MESSAGES']? + email = EmailHelper.parseEmail(email) return callback(new Error('invalid email')) if !email? data = {user_id, email} diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee index 9df6675b10..d0f76a4d27 100644 --- a/services/web/app/coffee/models/User.coffee +++ b/services/web/app/coffee/models/User.coffee @@ -79,6 +79,7 @@ UserSchema = new Schema refreshToken: { type: String } awareOfV2: { type:Boolean, default: false } thirdPartyIdentifiers: { type: Array, default: [] } + migratedAt: { type: Date } conn = mongoose.createConnection(Settings.mongo.url, { server: {poolSize: Settings.mongo.poolSize || 10}, diff --git a/services/web/test/unit/coffee/Project/ProjectDeleterTests.coffee b/services/web/test/unit/coffee/Project/ProjectDeleterTests.coffee index 08dcbcd7c3..73c6c29cb2 100644 --- a/services/web/test/unit/coffee/Project/ProjectDeleterTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectDeleterTests.coffee @@ -22,6 +22,8 @@ describe 'ProjectDeleter', -> db: deletedProjects: insert: sinon.stub().callsArg(1) + projectsDeletedByMigration: + insert: sinon.stub().callsArg(1) @Project = update: sinon.stub().callsArgWith(3) @@ -105,32 +107,32 @@ describe 'ProjectDeleter', -> @CollaboratorsHandler.removeUserFromAllProjets.calledWith(@user_id).should.equal true done() - describe "softDeleteUsersProjects", -> + describe "softDeleteUsersProjectsForMigrationForMigration", -> beforeEach -> - @deleter.softDeleteProject = sinon.stub().callsArg(1) + @deleter.softDeleteProjectForMigration = sinon.stub().callsArg(1) it "should find all the projects owned by the user_id", (done)-> - @deleter.softDeleteUsersProjects @user_id, => + @deleter.softDeleteUsersProjectsForMigration @user_id, => @Project.find.calledWith(owner_ref: @user_id).should.equal true done() it "should call deleteProject on the found projects", (done)-> - @deleter.softDeleteUsersProjects @user_id, => - sinon.assert.calledWith(@deleter.softDeleteProject, @project._id) + @deleter.softDeleteUsersProjectsForMigration @user_id, => + sinon.assert.calledWith(@deleter.softDeleteProjectForMigration, @project._id) done() it "should call deleteProject once for each project", (done)-> @Project.find.callsArgWith(1, null, [ {_id: 'potato'}, {_id: 'wombat'} ]) - @deleter.softDeleteUsersProjects @user_id, => - sinon.assert.calledTwice(@deleter.softDeleteProject) - sinon.assert.calledWith(@deleter.softDeleteProject, 'wombat') - sinon.assert.calledWith(@deleter.softDeleteProject, 'potato') + @deleter.softDeleteUsersProjectsForMigration @user_id, => + sinon.assert.calledTwice(@deleter.softDeleteProjectForMigration) + sinon.assert.calledWith(@deleter.softDeleteProjectForMigration, 'wombat') + sinon.assert.calledWith(@deleter.softDeleteProjectForMigration, 'potato') done() it "should remove all the projects the user is a collaborator of", (done)-> - @deleter.softDeleteUsersProjects @user_id, => + @deleter.softDeleteUsersProjectsForMigration @user_id, => @CollaboratorsHandler.removeUserFromAllProjets.calledWith(@user_id).should.equal true done() @@ -158,22 +160,22 @@ describe 'ProjectDeleter', -> }).should.equal true done() - describe "softDeleteProject", -> + describe "softDeleteProjectForMigration", -> beforeEach -> @deleter.deleteProject = sinon.stub().callsArg(1) it "should set the deletedAt time", (done)-> - @deleter.softDeleteProject @project_id, => + @deleter.softDeleteProjectForMigration @project_id, => @project.deletedAt.should.exist done() it "should insert the project into the deleted projects collection", (done)-> - @deleter.softDeleteProject @project_id, => - sinon.assert.calledWith(@mongojs.db.deletedProjects.insert, @project) + @deleter.softDeleteProjectForMigration @project_id, => + sinon.assert.calledWith(@mongojs.db.projectsDeletedByMigration.insert, @project) done() it "should delete the project", (done)-> - @deleter.softDeleteProject @project_id, => + @deleter.softDeleteProjectForMigration @project_id, => sinon.assert.calledWith(@deleter.deleteProject, @project_id) done() diff --git a/services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee index e8e184d329..e62c7d282b 100644 --- a/services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee @@ -117,6 +117,10 @@ describe 'ProjectDetailsHandler', -> sinon.assert.calledWith(@handler.generateUniqueName, '123', @project.name) done() + it "should append the supplied suffix to the project name, if passed", (done) -> + @handler.transferOwnership 'abc', '123', ' wombat', () => + sinon.assert.calledWith(@handler.generateUniqueName, '123', "#{@project.name} wombat") + done() describe "getProjectDescription", -> diff --git a/services/web/test/unit/coffee/User/UserDeleterTests.coffee b/services/web/test/unit/coffee/User/UserDeleterTests.coffee index 6f82e13d01..3ce09cc67b 100644 --- a/services/web/test/unit/coffee/User/UserDeleterTests.coffee +++ b/services/web/test/unit/coffee/User/UserDeleterTests.coffee @@ -20,7 +20,7 @@ describe "UserDeleter", -> @ProjectDeleter = deleteUsersProjects: sinon.stub().callsArgWith(1) - softDeleteUsersProjects: sinon.stub().callsArgWith(1) + softDeleteUsersProjectsForMigration: sinon.stub().callsArgWith(1) @SubscriptionHandler = cancelSubscription: sinon.stub().callsArgWith(1) @@ -37,6 +37,8 @@ describe "UserDeleter", -> db: deletedUsers: insert: sinon.stub().callsArg(1) + usersDeletedByMigration: + insert: sinon.stub().callsArg(1) @UserDeleter = SandboxedModule.require modulePath, requires: "../../models/User": User: @User @@ -50,46 +52,46 @@ describe "UserDeleter", -> "../../infrastructure/mongojs": @mongojs "logger-sharelatex": @logger = { log: sinon.stub() } - describe "softDeleteUser", -> + describe "softDeleteUserForMigration", -> it "should delete the user in mongo", (done)-> - @UserDeleter.softDeleteUser @user._id, (err)=> + @UserDeleter.softDeleteUserForMigration @user._id, (err)=> @User.findById.calledWith(@user._id).should.equal true @user.remove.called.should.equal true done() it "should add the user to the deletedUsers collection", (done)-> - @UserDeleter.softDeleteUser @user._id, (err)=> - sinon.assert.calledWith(@mongojs.db.deletedUsers.insert, @user) + @UserDeleter.softDeleteUserForMigration @user._id, (err)=> + sinon.assert.calledWith(@mongojs.db.usersDeletedByMigration.insert, @user) done() it "should set the deletedAt field on the user", (done)-> - @UserDeleter.softDeleteUser @user._id, (err)=> + @UserDeleter.softDeleteUserForMigration @user._id, (err)=> @user.deletedAt.should.exist done() it "should unsubscribe the user from the news letter", (done)-> - @UserDeleter.softDeleteUser @user._id, (err)=> + @UserDeleter.softDeleteUserForMigration @user._id, (err)=> @NewsletterManager.unsubscribe.calledWith(@user).should.equal true done() it "should unsubscribe the user", (done)-> - @UserDeleter.softDeleteUser @user._id, (err)=> + @UserDeleter.softDeleteUserForMigration @user._id, (err)=> @SubscriptionHandler.cancelSubscription.calledWith(@user).should.equal true done() it "should delete user affiliations", (done)-> - @UserDeleter.softDeleteUser @user._id, (err)=> + @UserDeleter.softDeleteUserForMigration @user._id, (err)=> @deleteAffiliations.calledWith(@user._id).should.equal true done() it "should soft-delete all the projects of a user", (done)-> - @UserDeleter.softDeleteUser @user._id, (err)=> - @ProjectDeleter.softDeleteUsersProjects.calledWith(@user._id).should.equal true + @UserDeleter.softDeleteUserForMigration @user._id, (err)=> + @ProjectDeleter.softDeleteUsersProjectsForMigration.calledWith(@user._id).should.equal true done() it "should remove user memberships", (done)-> - @UserDeleter.softDeleteUser @user._id, (err)=> + @UserDeleter.softDeleteUserForMigration @user._id, (err)=> @UserMembershipsHandler.removeUserFromAllEntities.calledWith(@user._id).should.equal true done()