InstitutionsAPI.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. const { callbackify } = require('util')
  2. const OError = require('@overleaf/o-error')
  3. const logger = require('@overleaf/logger')
  4. const settings = require('@overleaf/settings')
  5. const request = require('requestretry')
  6. const { promisifyAll } = require('@overleaf/promise-utils')
  7. const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
  8. const {
  9. V1ConnectionError,
  10. InvalidInstitutionalEmailError,
  11. } = require('../Errors/Errors')
  12. const { fetchJson, fetchNothing } = require('@overleaf/fetch-utils')
  13. const { promiseMapWithLimit } = require('@overleaf/promise-utils')
  14. const Modules = require('../../infrastructure/Modules')
  15. function _makeRequestOptions(options) {
  16. const requestOptions = {
  17. method: options.method,
  18. basicAuth: { user: settings.apis.v1.user, password: settings.apis.v1.pass },
  19. signal: AbortSignal.timeout(settings.apis.v1.timeout),
  20. }
  21. if (options.body) {
  22. requestOptions.json = options.body
  23. }
  24. return requestOptions
  25. }
  26. function _responseErrorHandling(options, error) {
  27. const status = error.response.status
  28. if (status >= 500) {
  29. throw new V1ConnectionError({
  30. message: 'error getting affiliations from v1',
  31. info: {
  32. status,
  33. body: error.body,
  34. },
  35. })
  36. }
  37. let errorBody
  38. try {
  39. if (error.body) {
  40. errorBody = JSON.parse(error.body)
  41. }
  42. } catch (e) {}
  43. let errorMessage
  44. if (errorBody?.errors) {
  45. errorMessage = `${status}: ${errorBody.errors}`
  46. } else {
  47. errorMessage = `${options.defaultErrorMessage}: ${status}`
  48. }
  49. throw new OError(errorMessage, { status })
  50. }
  51. async function _affiliationRequestFetchJson(options) {
  52. if (!settings.apis.v1.url) {
  53. return
  54. } // service is not configured
  55. const url = `${settings.apis.v1.url}${options.path}`
  56. const requestOptions = _makeRequestOptions(options)
  57. try {
  58. return await fetchJson(url, requestOptions)
  59. } catch (error) {
  60. _responseErrorHandling(options, error)
  61. }
  62. }
  63. async function _affiliationRequestFetchNothing(options) {
  64. if (!settings.apis.v1.url) {
  65. return
  66. } // service is not configured
  67. const url = `${settings.apis.v1.url}${options.path}`
  68. const requestOptions = _makeRequestOptions(options)
  69. try {
  70. await fetchNothing(url, requestOptions)
  71. } catch (error) {
  72. _responseErrorHandling(options, error)
  73. }
  74. }
  75. async function _affiliationRequestFetchNothing404Ok(options) {
  76. try {
  77. await _affiliationRequestFetchNothing(options)
  78. } catch (error) {
  79. const status = error.info?.status
  80. if (status !== 404) {
  81. throw error
  82. }
  83. }
  84. }
  85. function getInstitutionAffiliations(institutionId, callback) {
  86. makeAffiliationRequest(
  87. {
  88. method: 'GET',
  89. path: `/api/v2/institutions/${institutionId.toString()}/affiliations`,
  90. defaultErrorMessage: "Couldn't get institution affiliations",
  91. },
  92. (error, body) => callback(error, body || [])
  93. )
  94. }
  95. function getConfirmedInstitutionAffiliations(institutionId, callback) {
  96. makeAffiliationRequest(
  97. {
  98. method: 'GET',
  99. path: `/api/v2/institutions/${institutionId.toString()}/confirmed_affiliations`,
  100. defaultErrorMessage: "Couldn't get institution affiliations",
  101. },
  102. (error, body) => callback(error, body || [])
  103. )
  104. }
  105. function getInstitutionAffiliationsCounts(institutionId, callback) {
  106. makeAffiliationRequest(
  107. {
  108. method: 'GET',
  109. path: `/api/v2/institutions/${institutionId.toString()}/affiliations_counts`,
  110. defaultErrorMessage: "Couldn't get institution counts",
  111. },
  112. (error, body) => callback(error, body || [])
  113. )
  114. }
  115. function getLicencesForAnalytics(lag, queryDate, callback) {
  116. makeAffiliationRequest(
  117. {
  118. method: 'GET',
  119. path: `/api/v2/institutions/institutions_licences`,
  120. body: { query_date: queryDate, lag },
  121. defaultErrorMessage: 'Could not get institutions licences',
  122. },
  123. callback
  124. )
  125. }
  126. function getUserAffiliations(userId, callback) {
  127. makeAffiliationRequest(
  128. {
  129. method: 'GET',
  130. path: `/api/v2/users/${userId.toString()}/affiliations`,
  131. defaultErrorMessage: "Couldn't get user affiliations",
  132. },
  133. async (error, body) => {
  134. if (error) {
  135. return callback(error, [])
  136. }
  137. const affiliations = []
  138. if (body?.length > 0) {
  139. const concurrencyLimit = 10
  140. await promiseMapWithLimit(concurrencyLimit, body, async affiliation => {
  141. const group = (
  142. await Modules.promises.hooks.fire(
  143. 'getGroupWithDomainCaptureByV1Id',
  144. affiliation.institution.id
  145. )
  146. )?.[0]
  147. if (group) {
  148. affiliation.group = {
  149. _id: group._id,
  150. managedUsersEnabled: Boolean(group.managedUsersEnabled),
  151. domainCaptureEnabled: Boolean(group.domainCaptureEnabled),
  152. }
  153. }
  154. affiliations.push(affiliation)
  155. })
  156. }
  157. callback(null, affiliations)
  158. }
  159. )
  160. }
  161. async function getUsersNeedingReconfirmationsLapsedProcessed() {
  162. return await _affiliationRequestFetchJson({
  163. method: 'GET',
  164. path: '/api/v2/institutions/need_reconfirmation_lapsed_processed',
  165. defaultErrorMessage:
  166. 'Could not get users that need reconfirmations lapsed processed',
  167. })
  168. }
  169. async function addAffiliation(userId, email, affiliationOptions) {
  170. const {
  171. university,
  172. department,
  173. role,
  174. confirmedAt,
  175. entitlement,
  176. rejectIfBlocklisted,
  177. } = affiliationOptions
  178. try {
  179. await _affiliationRequestFetchNothing({
  180. method: 'POST',
  181. path: `/api/v2/users/${userId.toString()}/affiliations`,
  182. body: {
  183. email,
  184. university,
  185. department,
  186. role,
  187. confirmedAt,
  188. entitlement,
  189. rejectIfBlocklisted,
  190. },
  191. defaultErrorMessage: "Couldn't create affiliation",
  192. })
  193. } catch (error) {
  194. if (error.info?.status === 422) {
  195. throw new InvalidInstitutionalEmailError(error.message).withCause(error)
  196. }
  197. throw error
  198. }
  199. if (!university) {
  200. return
  201. }
  202. // have notifications delete any ip matcher notifications for this university
  203. try {
  204. await NotificationsBuilder.promises
  205. .ipMatcherAffiliation(userId)
  206. .read(university.id)
  207. } catch (err) {
  208. // log and ignore error
  209. logger.err({ err }, 'Something went wrong marking ip notifications read')
  210. }
  211. }
  212. async function removeAffiliation(userId, email) {
  213. await _affiliationRequestFetchNothing404Ok({
  214. method: 'POST',
  215. path: `/api/v2/users/${userId.toString()}/affiliations/remove`,
  216. body: { email },
  217. defaultErrorMessage: "Couldn't remove affiliation",
  218. })
  219. }
  220. function endorseAffiliation(userId, email, role, department, callback) {
  221. makeAffiliationRequest(
  222. {
  223. method: 'POST',
  224. path: `/api/v2/users/${userId.toString()}/affiliations/endorse`,
  225. body: { email, role, department },
  226. defaultErrorMessage: "Couldn't endorse affiliation",
  227. },
  228. callback
  229. )
  230. }
  231. function deleteAffiliations(userId, callback) {
  232. makeAffiliationRequest(
  233. {
  234. method: 'DELETE',
  235. path: `/api/v2/users/${userId.toString()}/affiliations`,
  236. defaultErrorMessage: "Couldn't delete affiliations",
  237. },
  238. callback
  239. )
  240. }
  241. function addEntitlement(userId, email, callback) {
  242. makeAffiliationRequest(
  243. {
  244. method: 'POST',
  245. path: `/api/v2/users/${userId}/affiliations/add_entitlement`,
  246. body: { email },
  247. defaultErrorMessage: "Couldn't add entitlement",
  248. },
  249. callback
  250. )
  251. }
  252. function removeEntitlement(userId, email, callback) {
  253. makeAffiliationRequest(
  254. {
  255. method: 'POST',
  256. path: `/api/v2/users/${userId}/affiliations/remove_entitlement`,
  257. body: { email },
  258. defaultErrorMessage: "Couldn't remove entitlement",
  259. extraSuccessStatusCodes: [404],
  260. },
  261. callback
  262. )
  263. }
  264. function sendUsersWithReconfirmationsLapsedProcessed(users, callback) {
  265. makeAffiliationRequest(
  266. {
  267. method: 'POST',
  268. path: '/api/v2/institutions/reconfirmation_lapsed_processed',
  269. body: { users },
  270. defaultErrorMessage:
  271. 'Could not update reconfirmation_lapsed_processed_at',
  272. },
  273. (error, body) => callback(error, body || [])
  274. )
  275. }
  276. const InstitutionsAPI = {
  277. getInstitutionAffiliations,
  278. getConfirmedInstitutionAffiliations,
  279. getInstitutionAffiliationsCounts,
  280. getLicencesForAnalytics,
  281. getUserAffiliations,
  282. getUsersNeedingReconfirmationsLapsedProcessed: callbackify(
  283. getUsersNeedingReconfirmationsLapsedProcessed
  284. ),
  285. addAffiliation: callbackify(addAffiliation),
  286. removeAffiliation: callbackify(removeAffiliation),
  287. endorseAffiliation,
  288. deleteAffiliations,
  289. addEntitlement,
  290. removeEntitlement,
  291. sendUsersWithReconfirmationsLapsedProcessed,
  292. }
  293. function makeAffiliationRequest(options, callback) {
  294. if (!settings.apis.v1.url) {
  295. return callback(null)
  296. } // service is not configured
  297. if (!options.extraSuccessStatusCodes) {
  298. options.extraSuccessStatusCodes = []
  299. }
  300. const requestOptions = {
  301. method: options.method,
  302. url: `${settings.apis.v1.url}${options.path}`,
  303. body: options.body,
  304. auth: { user: settings.apis.v1.user, pass: settings.apis.v1.pass },
  305. json: true,
  306. timeout: settings.apis.v1.timeout,
  307. }
  308. if (options.method === 'GET') {
  309. requestOptions.maxAttempts = 3
  310. requestOptions.retryDelay = 500
  311. } else {
  312. requestOptions.maxAttempts = 0
  313. }
  314. request(requestOptions, function (error, response, body) {
  315. if (error) {
  316. return callback(
  317. new V1ConnectionError('error getting affiliations from v1').withCause(
  318. error
  319. )
  320. )
  321. }
  322. if (response && response.statusCode >= 500) {
  323. return callback(
  324. new V1ConnectionError({
  325. message: 'error getting affiliations from v1',
  326. info: {
  327. status: response.statusCode,
  328. body,
  329. },
  330. })
  331. )
  332. }
  333. let isSuccess = response.statusCode >= 200 && response.statusCode < 300
  334. if (!isSuccess) {
  335. isSuccess = options.extraSuccessStatusCodes.includes(response.statusCode)
  336. }
  337. if (!isSuccess) {
  338. let errorMessage
  339. if (body && body.errors) {
  340. errorMessage = `${response.statusCode}: ${body.errors}`
  341. } else {
  342. errorMessage = `${options.defaultErrorMessage}: ${response.statusCode}`
  343. }
  344. logger.warn({ path: options.path, body: options.body }, errorMessage)
  345. return callback(
  346. new OError(errorMessage, { statusCode: response.statusCode })
  347. )
  348. }
  349. callback(null, body)
  350. })
  351. }
  352. InstitutionsAPI.promises = promisifyAll(InstitutionsAPI, {
  353. without: [
  354. 'addAffiliation',
  355. 'removeAffiliation',
  356. 'getUsersNeedingReconfirmationsLapsedProcessed',
  357. ],
  358. })
  359. InstitutionsAPI.promises.addAffiliation = addAffiliation
  360. InstitutionsAPI.promises.removeAffiliation = removeAffiliation
  361. InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed =
  362. getUsersNeedingReconfirmationsLapsedProcessed
  363. module.exports = InstitutionsAPI