InstitutionsAPI.js 9.8 KB

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