fix_unconfirmed_secondaries_not_removed_v1.mjs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. // @ts-check
  2. import { db, ObjectId } from '../app/src/infrastructure/mongodb.mjs'
  3. import fs from 'node:fs/promises'
  4. import * as csv from 'csv'
  5. import { promisify } from 'node:util'
  6. import { scriptRunner } from './lib/ScriptRunner.mjs'
  7. import { READ_PREFERENCE_SECONDARY } from '@overleaf/mongo-utils/batchedUpdate.js'
  8. import { fetchJson, fetchNothing } from '@overleaf/fetch-utils'
  9. import Settings from '@overleaf/settings'
  10. import path from 'path-browserify'
  11. import { fileURLToPath } from 'node:url'
  12. import minimist from 'minimist'
  13. const __dirname = path.dirname(fileURLToPath(import.meta.url))
  14. const CSV_FILENAME = './tmp/unconfirmed_emails_removed.csv'
  15. const argv = minimist(process.argv.slice(2))
  16. const commit = argv.commit === 'true'
  17. const doNotListUsers = argv.do_not_list_users === 'true'
  18. console.log(
  19. 'Begin remove affiliations not removed in v1 when unconfirmed secondary email was removed from user account'
  20. )
  21. if (commit) {
  22. console.log('\nRunning in COMMIT mode, changes will be made in v1\n')
  23. } else {
  24. console.log(
  25. '\nRunning in DRY-RUN mode, no changes will be made in v1, only reporting results. To commit changes run with --commit=true\n'
  26. )
  27. }
  28. if (!doNotListUsers) {
  29. console.log(
  30. 'Full results lists will be outputed. To not list users run with --do_not_list_users=true\n'
  31. )
  32. }
  33. /**
  34. * @type {(csvString: string) => Promise<string[][]>}
  35. */
  36. const parseAsync = promisify(csv.parse)
  37. /**
  38. * @param {any} userId
  39. */
  40. async function getV1Affiliations(userId) {
  41. const url = `${Settings.apis.v1.url}/api/v2/users/${userId}/affiliations`
  42. const affiliations = await fetchJson(url, {
  43. basicAuth: {
  44. user: Settings.apis.v1.user,
  45. password: Settings.apis.v1.pass,
  46. },
  47. signal: AbortSignal.timeout(Settings.apis.v1.timeout),
  48. })
  49. return affiliations
  50. }
  51. /**
  52. * @param {any} userId
  53. * @param {any} email
  54. */
  55. async function removeAffiliationV1(userId, email) {
  56. const url = `${Settings.apis.v1.url}/api/v2/users/${userId}/affiliations/remove`
  57. await fetchNothing(url, {
  58. method: 'POST',
  59. json: { email },
  60. basicAuth: {
  61. user: Settings.apis.v1.user,
  62. password: Settings.apis.v1.pass,
  63. },
  64. signal: AbortSignal.timeout(Settings.apis.v1.timeout),
  65. defaultErrorMessage: "Couldn't remove affiliation",
  66. })
  67. }
  68. const results = {
  69. /** @type {string[]} */
  70. userNotFound: [],
  71. /** @type {string[]} */
  72. userNotFoundInV1: [],
  73. /** @type {{email: string, userId: string}[]} */
  74. emailNotInV1ForUser: [],
  75. /** @type {{email: string, userId: string}[]} */
  76. needToRemoveEmailInV1: [],
  77. /** @type {{email: string, userId: string}[]} */
  78. successfullyRemovedEmailInV1ForUser: [],
  79. /** @type {{email: string, userId: string}[]} */
  80. emailStillOnAccount: [],
  81. /** @type {{email: string, userId: string}[]} */
  82. emailNowOnOtherAccount: [],
  83. /** @type {string[]} */
  84. errorCheckingAffiliations: [],
  85. /** @type {{email: string, userId: string, status?: number}[]} */
  86. errorRemovingAffiliationInV1: [],
  87. }
  88. /**
  89. * @param {any} trackProgress
  90. */
  91. async function main(trackProgress) {
  92. console.time('check_removed_emails')
  93. const filePath = path.join(__dirname, CSV_FILENAME)
  94. const csvContent = await fs.readFile(filePath, 'utf8')
  95. const rows = await parseAsync(csvContent)
  96. rows.shift() // Remove header row
  97. /** @type {Record<string, string[]>} */
  98. const emailsByUserId = {}
  99. for (const [userId, email] of rows) {
  100. if (!emailsByUserId[userId]) {
  101. emailsByUserId[userId] = []
  102. }
  103. emailsByUserId[userId].push(email.trim())
  104. }
  105. const userIds = Object.keys(emailsByUserId)
  106. let processedUsersCount = 0
  107. console.log('Total emails in the CSV:', rows.length)
  108. console.log('Total users in the CSV:', userIds.length)
  109. for (const userId of userIds) {
  110. const removedEmails = emailsByUserId[userId] // these will be emails that had affiliations and not. they were removed from the user account, but we didn't remove the affiliations in v1 (if they existed)
  111. let affiliations
  112. try {
  113. affiliations = await getV1Affiliations(userId)
  114. if (!affiliations.length) {
  115. results.userNotFoundInV1.push(userId)
  116. // nothing to cleanup in v1 if no affiliations for the user
  117. continue
  118. }
  119. } catch (/** @type {any} */ e) {
  120. results.errorCheckingAffiliations.push(userId)
  121. }
  122. const affiliationsEmailsInV1 = affiliations.map(
  123. (/** @type {any} */ affiliation) => affiliation.email
  124. )
  125. const user = await db.users.findOne(
  126. { _id: new ObjectId(userId) },
  127. { readPreference: READ_PREFERENCE_SECONDARY, projection: { emails: 1 } }
  128. )
  129. if (!user) {
  130. // user was deleted but their affiliations still persist in v1,
  131. // we should cleanup v1, otherwise email cannot be added to other accounts
  132. results.userNotFound.push(userId)
  133. }
  134. for (const email of removedEmails) {
  135. if (!affiliationsEmailsInV1.includes(email)) {
  136. // the email removed is not in v1 affiliations for the user ID it was removed from,
  137. // this is expected and good (either email had no affiliation or somehow the affiliation did get removed), no cleanup needed in v1
  138. results.emailNotInV1ForUser.push({ userId, email })
  139. continue
  140. }
  141. const emailOnAccount = user?.emails?.find(
  142. (/** @type {any} */ e) => e.email === email
  143. )
  144. if (emailOnAccount) {
  145. // the email is still on the user account, we should not remove the affiliation in v1
  146. results.emailStillOnAccount.push({ userId, email })
  147. continue
  148. } else {
  149. // we'll remove the email affiliation in v1 but let's also check if the email is now on another user's account
  150. // this should error but if it did get added then it would be added without an affiliation
  151. // possibly ok because maybe email is not affiliated, but worth investigating if this happened because if it was added without an affiliation then that
  152. // could put the user into a bad state (no access to Commons license, no visibility in metrics, not captured by group with domain capture, etc)
  153. const query = { emails: { $exists: true }, 'emails.email': email } // $exists: true MUST be set to use the partial index
  154. const otherUserWithEmail = await db.users.findOne(query, {
  155. readPreference: READ_PREFERENCE_SECONDARY,
  156. })
  157. if (otherUserWithEmail) {
  158. results.emailNowOnOtherAccount.push({
  159. email,
  160. userId: otherUserWithEmail._id.toString(),
  161. })
  162. }
  163. }
  164. results.needToRemoveEmailInV1.push({ userId, email })
  165. if (commit) {
  166. // only make the changes if script arg is 'commit', otherwise just report results
  167. try {
  168. // remove the affiliation in v1
  169. await removeAffiliationV1(userId, email)
  170. results.successfullyRemovedEmailInV1ForUser.push({ userId, email })
  171. } catch (/** @type {any} */ e) {
  172. results.errorRemovingAffiliationInV1.push({
  173. userId,
  174. email,
  175. // @ts-ignore
  176. status: e.info?.status,
  177. })
  178. }
  179. }
  180. }
  181. processedUsersCount++
  182. if (processedUsersCount % 100 === 0) {
  183. trackProgress(`Processed ${processedUsersCount} users`)
  184. }
  185. }
  186. console.log('Results:')
  187. for (const key in results) {
  188. console.log(` ${key}:`, /** @type {any} */ (results)[key].length)
  189. }
  190. for (const key in results) {
  191. if (
  192. !doNotListUsers &&
  193. /** @type {any} */ (results)[key].length > 0 &&
  194. key !== 'needToRemoveEmailInV1'
  195. ) {
  196. // skip needToRemoveEmailInV1 since we'll only output that if this list length does not match success list length
  197. console.log('----------------------------')
  198. console.log(`${key}:`)
  199. console.log(/** @type {any} */ (results)[key])
  200. }
  201. }
  202. if (
  203. commit &&
  204. !doNotListUsers &&
  205. results.needToRemoveEmailInV1.length !==
  206. results.successfullyRemovedEmailInV1ForUser.length
  207. ) {
  208. // avoid outputting this list twice since it will be quite long. Only output those that need to be removed and were not successfully removed
  209. const expectedToBeRemovedButWerent = results.needToRemoveEmailInV1.filter(
  210. needToRemove =>
  211. !results.successfullyRemovedEmailInV1ForUser.some(
  212. successfullyRemoved =>
  213. successfullyRemoved.userId === needToRemove.userId &&
  214. successfullyRemoved.email === needToRemove.email
  215. )
  216. )
  217. console.log(
  218. '----------------------------\nEmails that needed to be removed in v1 but were not successfully removed:'
  219. )
  220. console.log(expectedToBeRemovedButWerent)
  221. }
  222. }
  223. try {
  224. await scriptRunner(main)
  225. process.exit(0)
  226. } catch (error) {
  227. console.error(error)
  228. process.exit(1)
  229. }