InstitutionsAPI.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. import { callbackify } from 'node:util'
  2. import OError from '@overleaf/o-error'
  3. import logger from '@overleaf/logger'
  4. import settings from '@overleaf/settings'
  5. import request from 'requestretry'
  6. import { promisify, promiseMapWithLimit } from '@overleaf/promise-utils'
  7. import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
  8. import {
  9. V1ConnectionError,
  10. InvalidInstitutionalEmailError,
  11. } from '../Errors/Errors.js'
  12. import { fetchJson, fetchNothing } from '@overleaf/fetch-utils'
  13. import Modules from '../../infrastructure/Modules.mjs'
  14. function _makeRequestOptions(options) {
  15. const requestOptions = {
  16. method: options.method,
  17. basicAuth: { user: settings.apis.v1.user, password: settings.apis.v1.pass },
  18. signal: AbortSignal.timeout(settings.apis.v1.timeout),
  19. }
  20. if (options.body) {
  21. requestOptions.json = options.body
  22. }
  23. return requestOptions
  24. }
  25. function _responseErrorHandling(options, error) {
  26. const status = error.response.status
  27. if (status >= 500) {
  28. throw new V1ConnectionError({
  29. message: 'error getting affiliations from v1',
  30. info: {
  31. status,
  32. body: error.body,
  33. },
  34. })
  35. }
  36. let errorBody
  37. try {
  38. if (error.body) {
  39. errorBody = JSON.parse(error.body)
  40. }
  41. } catch (e) {}
  42. let errorMessage
  43. if (errorBody?.errors) {
  44. errorMessage = `${status}: ${errorBody.errors}`
  45. } else {
  46. errorMessage = `${options.defaultErrorMessage}: ${status}`
  47. }
  48. throw new OError(errorMessage, { status })
  49. }
  50. async function _affiliationRequestFetchJson(options) {
  51. if (!settings.apis.v1.url) {
  52. return
  53. } // service is not configured
  54. const url = `${settings.apis.v1.url}${options.path}`
  55. const requestOptions = _makeRequestOptions(options)
  56. try {
  57. return await fetchJson(url, requestOptions)
  58. } catch (error) {
  59. _responseErrorHandling(options, error)
  60. }
  61. }
  62. async function _affiliationRequestFetchNothing(options) {
  63. if (!settings.apis.v1.url) {
  64. return
  65. } // service is not configured
  66. const url = `${settings.apis.v1.url}${options.path}`
  67. const requestOptions = _makeRequestOptions(options)
  68. try {
  69. await fetchNothing(url, requestOptions)
  70. } catch (error) {
  71. _responseErrorHandling(options, error)
  72. }
  73. }
  74. async function _affiliationRequestFetchNothing404Ok(options) {
  75. try {
  76. await _affiliationRequestFetchNothing(options)
  77. } catch (error) {
  78. const status = error.info?.status
  79. if (status !== 404) {
  80. throw error
  81. }
  82. }
  83. }
  84. function getInstitutionAffiliations(institutionId, callback) {
  85. makeAffiliationRequest(
  86. {
  87. method: 'GET',
  88. path: `/api/v2/institutions/${institutionId.toString()}/affiliations`,
  89. defaultErrorMessage: "Couldn't get institution affiliations",
  90. },
  91. (error, body) => callback(error, body || [])
  92. )
  93. }
  94. function getConfirmedInstitutionAffiliations(institutionId, callback) {
  95. makeAffiliationRequest(
  96. {
  97. method: 'GET',
  98. path: `/api/v2/institutions/${institutionId.toString()}/confirmed_affiliations`,
  99. defaultErrorMessage: "Couldn't get institution affiliations",
  100. },
  101. (error, body) => callback(error, body || [])
  102. )
  103. }
  104. function getInstitutionAffiliationsCounts(institutionId, callback) {
  105. makeAffiliationRequest(
  106. {
  107. method: 'GET',
  108. path: `/api/v2/institutions/${institutionId.toString()}/affiliations_counts`,
  109. defaultErrorMessage: "Couldn't get institution counts",
  110. },
  111. (error, body) => callback(error, body || [])
  112. )
  113. }
  114. function getLicencesForAnalytics(lag, queryDate, callback) {
  115. makeAffiliationRequest(
  116. {
  117. method: 'GET',
  118. path: `/api/v2/institutions/institutions_licences`,
  119. body: { query_date: queryDate, lag },
  120. defaultErrorMessage: 'Could not get institutions licences',
  121. timeout: 60_000,
  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.toString())
  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 timeout = options.timeout ? options.timeout : settings.apis.v1.timeout
  301. const requestOptions = {
  302. method: options.method,
  303. url: `${settings.apis.v1.url}${options.path}`,
  304. body: options.body,
  305. auth: { user: settings.apis.v1.user, pass: settings.apis.v1.pass },
  306. json: true,
  307. timeout,
  308. }
  309. if (options.method === 'GET') {
  310. requestOptions.maxAttempts = 3
  311. requestOptions.retryDelay = 500
  312. } else {
  313. requestOptions.maxAttempts = 0
  314. }
  315. request(requestOptions, function (error, response, body) {
  316. if (error) {
  317. return callback(
  318. new V1ConnectionError('error getting affiliations from v1').withCause(
  319. error
  320. )
  321. )
  322. }
  323. if (response && response.statusCode >= 500) {
  324. return callback(
  325. new V1ConnectionError({
  326. message: 'error getting affiliations from v1',
  327. info: {
  328. status: response.statusCode,
  329. body,
  330. },
  331. })
  332. )
  333. }
  334. let isSuccess = response.statusCode >= 200 && response.statusCode < 300
  335. if (!isSuccess) {
  336. isSuccess = options.extraSuccessStatusCodes.includes(response.statusCode)
  337. }
  338. if (!isSuccess) {
  339. let errorMessage
  340. if (body && body.errors) {
  341. errorMessage = `${response.statusCode}: ${body.errors}`
  342. } else {
  343. errorMessage = `${options.defaultErrorMessage}: ${response.statusCode}`
  344. }
  345. logger.warn({ path: options.path, body: options.body }, errorMessage)
  346. return callback(
  347. new OError(errorMessage, { statusCode: response.statusCode })
  348. )
  349. }
  350. callback(null, body)
  351. })
  352. }
  353. InstitutionsAPI.promises = {
  354. getInstitutionAffiliations: promisify(
  355. InstitutionsAPI.getInstitutionAffiliations
  356. ),
  357. getConfirmedInstitutionAffiliations: promisify(
  358. InstitutionsAPI.getConfirmedInstitutionAffiliations
  359. ),
  360. getInstitutionAffiliationsCounts: promisify(
  361. InstitutionsAPI.getInstitutionAffiliationsCounts
  362. ),
  363. getLicencesForAnalytics: promisify(InstitutionsAPI.getLicencesForAnalytics),
  364. getUserAffiliations: promisify(InstitutionsAPI.getUserAffiliations),
  365. getUsersNeedingReconfirmationsLapsedProcessed,
  366. addAffiliation,
  367. removeAffiliation,
  368. endorseAffiliation: promisify(InstitutionsAPI.endorseAffiliation),
  369. deleteAffiliations: promisify(InstitutionsAPI.deleteAffiliations),
  370. addEntitlement: promisify(InstitutionsAPI.addEntitlement),
  371. removeEntitlement: promisify(InstitutionsAPI.removeEntitlement),
  372. sendUsersWithReconfirmationsLapsedProcessed: promisify(
  373. InstitutionsAPI.sendUsersWithReconfirmationsLapsedProcessed
  374. ),
  375. }
  376. export default InstitutionsAPI