ProjectListController.mjs 29 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004
  1. // @ts-check
  2. import _ from 'lodash'
  3. import moment from 'moment'
  4. import Metrics from '@overleaf/metrics'
  5. import Settings from '@overleaf/settings'
  6. import ProjectHelper from './ProjectHelper.mjs'
  7. import ProjectGetter from './ProjectGetter.mjs'
  8. import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs'
  9. import SessionManager from '../Authentication/SessionManager.mjs'
  10. import Sources from '../Authorization/Sources.mjs'
  11. import UserGetter from '../User/UserGetter.mjs'
  12. import SurveyHandler from '../Survey/SurveyHandler.mjs'
  13. import TagsHandler from '../Tags/TagsHandler.mjs'
  14. import { expressify } from '@overleaf/promise-utils'
  15. import logger from '@overleaf/logger'
  16. import Features from '../../infrastructure/Features.mjs'
  17. import SubscriptionViewModelBuilder from '../Subscription/SubscriptionViewModelBuilder.mjs'
  18. import NotificationsHandler from '../Notifications/NotificationsHandler.mjs'
  19. import Modules from '../../infrastructure/Modules.mjs'
  20. import { OError, V1ConnectionError } from '../Errors/Errors.js'
  21. import { User } from '../../models/User.mjs'
  22. import UserPrimaryEmailCheckHandler from '../User/UserPrimaryEmailCheckHandler.mjs'
  23. import UserController from '../User/UserController.mjs'
  24. import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
  25. import GeoIpLookup from '../../infrastructure/GeoIpLookup.mjs'
  26. import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
  27. import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.mjs'
  28. import TutorialHandler from '../Tutorial/TutorialHandler.mjs'
  29. import SubscriptionHelper from '../Subscription/SubscriptionHelper.mjs'
  30. import CustomerIoPlanHelpers from '../Subscription/CustomerIoPlanHelpers.mjs'
  31. import PermissionsManager from '../Authorization/PermissionsManager.mjs'
  32. import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
  33. import { OnboardingDataCollection } from '../../models/OnboardingDataCollection.mjs'
  34. import UserSettingsHelper from './UserSettingsHelper.mjs'
  35. /**
  36. * @import { GetProjectsRequest, GetProjectsResponse, AllUsersProjects, MongoProject, FormattedProject, MongoTag, SubscriptionRecord } from "./types"
  37. * @import { Project, ProjectApi, ProjectAccessLevel, Filters, Page, Sort, UserRef } from "../../../../types/project/dashboard/api"
  38. * @import { Affiliation } from "../../../../types/affiliation"
  39. * @import { Source } from "../Authorization/types"
  40. */
  41. /**
  42. * @param {Affiliation} affiliation
  43. * @param {any} session
  44. * @param {string[]} linkedInstitutionIds
  45. * @returns {boolean}
  46. * @private
  47. */
  48. const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => {
  49. if (!affiliation.institution) return false
  50. // institution.confirmed is for the domain being confirmed, not the email
  51. // Do not show SSO UI for unconfirmed domains
  52. if (!affiliation.institution.confirmed) return false
  53. // If ssoEnabled = true and group.domainCaptureEnabled = true
  54. // then Commons is migrating to group subscription and we do not want to prompt
  55. // linking through Commons SSO
  56. if (affiliation?.group?.domainCaptureEnabled) return false
  57. // Could have multiple emails at the same institution, and if any are
  58. // linked to the institution then do not show notification for others
  59. if (
  60. linkedInstitutionIds.indexOf(affiliation.institution.id.toString()) === -1
  61. ) {
  62. if (affiliation.institution.ssoEnabled) return true
  63. if (affiliation.institution.ssoBeta && session.samlBeta) return true
  64. return false
  65. }
  66. return false
  67. }
  68. /**
  69. * @param {Affiliation[]} affiliations
  70. * @returns {Array<{ name: string, url: string }>}
  71. */
  72. const _buildPortalTemplatesList = affiliations => {
  73. if (affiliations == null) {
  74. affiliations = []
  75. }
  76. const portalTemplates = []
  77. const uniqueAffiliations = _.uniqBy(affiliations, 'institution.id')
  78. for (const aff of uniqueAffiliations) {
  79. const hasSlug = aff.portal?.slug
  80. const hasTemplates = (aff.portal?.templates_count || 0) > 0
  81. if (hasSlug && hasTemplates) {
  82. const portalPath = aff.institution.isUniversity ? '/edu/' : '/org/'
  83. const portalTemplateURL = Settings.siteUrl + portalPath + aff.portal?.slug
  84. portalTemplates.push({
  85. name: aff.institution.name,
  86. url: portalTemplateURL,
  87. })
  88. }
  89. }
  90. return portalTemplates
  91. }
  92. /**
  93. * @param {any} req
  94. */
  95. function cleanupSession(req) {
  96. // cleanup redirects at the end of the redirect chain
  97. delete req.session.postCheckoutRedirect
  98. delete req.session.postLoginRedirect
  99. delete req.session.postOnboardingRedirect
  100. // cleanup details from register page
  101. delete req.session.sharedProjectData
  102. delete req.session.templateData
  103. }
  104. /**
  105. * @param {import("express").Request} req
  106. * @param {import("express").Response} res
  107. * @param {import("express").NextFunction} next
  108. * @returns {Promise<void>}
  109. */
  110. async function projectListPage(req, res, next) {
  111. cleanupSession(req)
  112. // can have two values:
  113. // - undefined - when there's no "saas" feature or couldn't get subscription data
  114. // - object - the subscription data object
  115. let usersBestSubscription
  116. let usersIndividualSubscription
  117. /** @type {any[]} */
  118. let usersGroupSubscriptions = []
  119. /** @type {any[]} */
  120. let usersManagedGroupSubscriptions = []
  121. let survey
  122. let userIsMemberOfGroupSubscription = false
  123. /** @type {any[]} */
  124. let groupSubscriptionsPendingEnrollment = []
  125. const isSaas = Features.hasFeature('saas')
  126. const userId = SessionManager.getLoggedInUserId(req.session)
  127. if (isSaas) {
  128. const { variant: domainCaptureRedirect } =
  129. await SplitTestHandler.promises.getAssignment(
  130. req,
  131. res,
  132. 'domain-capture-redirect'
  133. )
  134. if (domainCaptureRedirect === 'enabled') {
  135. const groupsWithEmails = (
  136. await Modules.promises.hooks.fire(
  137. 'findDomainCaptureGroupsUserCouldBePartOf',
  138. userId
  139. )
  140. )?.[0]
  141. if (groupsWithEmails && groupsWithEmails.length > 0) {
  142. if (
  143. groupsWithEmails.some(
  144. (/** @type {any} */ { subscription }) =>
  145. subscription.managedUsersEnabled
  146. )
  147. ) {
  148. return res.redirect('/domain-capture')
  149. } else {
  150. // TODO show notification or anything else
  151. }
  152. }
  153. }
  154. }
  155. const projectsBlobPending = _getProjects(userId).catch(err => {
  156. logger.err({ err, userId }, 'projects listing in background failed')
  157. return undefined
  158. })
  159. const user = await User.findById(
  160. userId,
  161. `email isAdmin emails features alphaProgram betaProgram lastPrimaryEmailCheck lastActive signUpDate ace refProviders${
  162. isSaas
  163. ? ' enrollment writefull completedTutorials aiFeatures aiErrorAssistant labsProgram'
  164. : ''
  165. }`
  166. )
  167. // Handle case of deleted user
  168. if (user == null) {
  169. UserController.logout(req, res, next)
  170. return
  171. }
  172. user.refProviders = _.mapValues(user.refProviders, Boolean)
  173. let onboardingDataCollection
  174. let customerIoEnabled = false
  175. let subjectArea
  176. let usedLatex
  177. let primaryOccupation
  178. let role
  179. if (isSaas) {
  180. if (user.isAdmin) {
  181. await _checkForOldDebugProjects(userId).catch(err => {
  182. logger.warn(
  183. { err, userId },
  184. 'failed to check old debug projects/managing notifications'
  185. )
  186. })
  187. }
  188. await SplitTestSessionHandler.promises.sessionMaintenance(req, user)
  189. try {
  190. ;({
  191. bestSubscription: usersBestSubscription,
  192. individualSubscription: usersIndividualSubscription,
  193. memberGroupSubscriptions: usersGroupSubscriptions,
  194. managedGroupSubscriptions: usersManagedGroupSubscriptions,
  195. } = await SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails(
  196. { _id: userId }
  197. ))
  198. } catch (error) {
  199. logger.err(
  200. { err: error, userId },
  201. "Failed to get user's best subscription"
  202. )
  203. }
  204. userIsMemberOfGroupSubscription =
  205. usersGroupSubscriptions.length > 0 ||
  206. usersManagedGroupSubscriptions.length > 0
  207. // TODO use helper function
  208. if (!user.enrollment?.managedBy) {
  209. groupSubscriptionsPendingEnrollment = usersGroupSubscriptions.filter(
  210. subscription =>
  211. subscription.groupPlan && subscription.managedUsersEnabled
  212. )
  213. }
  214. try {
  215. survey = await SurveyHandler.promises.getSurvey(userId)
  216. } catch (error) {
  217. logger.err({ err: error, userId }, 'Failed to load the active survey')
  218. }
  219. if (
  220. user &&
  221. UserPrimaryEmailCheckHandler.requiresPrimaryEmailCheck({
  222. email: user.email,
  223. emails: user.emails,
  224. lastPrimaryEmailCheck: user.lastPrimaryEmailCheck,
  225. signUpDate: user.signUpDate,
  226. })
  227. ) {
  228. return res.redirect('/user/emails/primary-email-check')
  229. }
  230. onboardingDataCollection = await OnboardingDataCollection.findById(
  231. userId,
  232. 'subjectArea usedLatex primaryOccupation role'
  233. )
  234. if (onboardingDataCollection) {
  235. subjectArea = onboardingDataCollection.subjectArea
  236. usedLatex = onboardingDataCollection.usedLatex
  237. primaryOccupation = onboardingDataCollection.primaryOccupation
  238. role = onboardingDataCollection.role
  239. }
  240. customerIoEnabled = true
  241. AnalyticsManager.setUserPropertyForUserInBackground(
  242. userId,
  243. 'customer-io-integration',
  244. true
  245. )
  246. }
  247. const tags = await TagsHandler.promises.getAllTags(userId)
  248. /** @type {{ list: any[], allInReconfirmNotificationPeriods?: any[], error?: any }} */
  249. let userEmailsData = {
  250. list: [],
  251. }
  252. try {
  253. const fullEmails = await UserGetter.promises.getUserFullEmails(userId)
  254. if (!Features.hasFeature('affiliations')) {
  255. userEmailsData.list = fullEmails
  256. } else {
  257. try {
  258. const results = await Modules.promises.hooks.fire(
  259. 'allInReconfirmNotificationPeriodsForUser',
  260. fullEmails
  261. )
  262. const allInReconfirmNotificationPeriods = (results && results[0]) || []
  263. userEmailsData = {
  264. list: fullEmails,
  265. allInReconfirmNotificationPeriods,
  266. }
  267. } catch (error) {
  268. userEmailsData.error = error
  269. }
  270. }
  271. } catch (error) {
  272. if (!(error instanceof V1ConnectionError)) {
  273. logger.error({ err: error, userId }, 'Failed to get user full emails')
  274. }
  275. }
  276. const userEmails = userEmailsData.list || []
  277. const userAffiliations = userEmails
  278. .filter(emailData => !!emailData.affiliation)
  279. .map(emailData => {
  280. const result = emailData.affiliation
  281. result.email = emailData.email
  282. return result
  283. })
  284. const commonsInstitution = userAffiliations.find(
  285. affiliation => affiliation.institution?.commonsAccount
  286. )?.institution?.name
  287. const portalTemplates = _buildPortalTemplatesList(userAffiliations)
  288. const { allInReconfirmNotificationPeriods } = userEmailsData
  289. const notifications =
  290. await NotificationsHandler.promises.getUserNotifications(userId)
  291. for (const notification of notifications) {
  292. notification.html = req.i18n.translate(
  293. notification.templateKey,
  294. notification.messageOpts
  295. )
  296. }
  297. const notificationsInstitution = []
  298. // Institution and group SSO Notifications
  299. let groupSsoSetupSuccess
  300. let viaDomainCapture
  301. let joinedGroupName = ''
  302. let reconfirmedViaSAML
  303. if (Features.hasFeature('saml')) {
  304. reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed'])
  305. const samlSession = req.session.saml
  306. // Notification: SSO Available
  307. /** @type {string[]} */
  308. const linkedInstitutionIds = []
  309. userEmails.forEach(email => {
  310. if (email.samlProviderId) {
  311. linkedInstitutionIds.push(email.samlProviderId)
  312. }
  313. })
  314. if (Array.isArray(userAffiliations)) {
  315. userAffiliations.forEach(affiliation => {
  316. if (_ssoAvailable(affiliation, req.session, linkedInstitutionIds)) {
  317. notificationsInstitution.push({
  318. email: affiliation.email,
  319. institutionId: affiliation.institution.id,
  320. institutionName: affiliation.institution.name,
  321. templateKey: 'notification_institution_sso_available',
  322. })
  323. }
  324. })
  325. }
  326. if (samlSession) {
  327. // Notification institution SSO: After SSO Linked
  328. if (samlSession.linked) {
  329. let templateKey = 'notification_institution_sso_linked'
  330. if (
  331. samlSession.userCreatedViaDomainCapture &&
  332. samlSession.managedUsersEnabled
  333. ) {
  334. templateKey =
  335. 'notification_account_created_via_group_domain_capture_and_managed_users_enabled'
  336. } else if (samlSession.domainCaptureEnabled) {
  337. templateKey = 'notification_group_sso_linked'
  338. }
  339. notificationsInstitution.push({
  340. email: samlSession.institutionEmail,
  341. institutionName:
  342. samlSession.linked.universityName ||
  343. samlSession.linked.providerName,
  344. templateKey,
  345. })
  346. }
  347. // Notification group SSO: After SSO Linked
  348. if (samlSession.linkedGroup) {
  349. groupSsoSetupSuccess = true
  350. viaDomainCapture = samlSession.domainCaptureJoin
  351. joinedGroupName = samlSession.universityName
  352. }
  353. // Notification institution SSO: After SSO Linked or Logging in
  354. // The requested email does not match primary email returned from
  355. // the institution
  356. if (
  357. samlSession.requestedEmail &&
  358. samlSession.emailNonCanonical &&
  359. !samlSession.error
  360. ) {
  361. notificationsInstitution.push({
  362. institutionEmail: samlSession.emailNonCanonical,
  363. requestedEmail: samlSession.requestedEmail,
  364. templateKey: 'notification_institution_sso_non_canonical',
  365. })
  366. }
  367. // Notification institution SSO: Tried to register, but account already existed
  368. // registerIntercept is set before the institution callback.
  369. // institutionEmail is set after institution callback.
  370. // Check for both in case SSO flow was abandoned
  371. if (
  372. samlSession.registerIntercept &&
  373. samlSession.institutionEmail &&
  374. !samlSession.error
  375. ) {
  376. notificationsInstitution.push({
  377. email: samlSession.institutionEmail,
  378. templateKey: 'notification_institution_sso_already_registered',
  379. })
  380. }
  381. // Notification: When there is a session error
  382. if (samlSession.error) {
  383. notificationsInstitution.push({
  384. templateKey: 'notification_institution_sso_error',
  385. error: samlSession.error,
  386. })
  387. }
  388. }
  389. delete req.session.saml
  390. }
  391. const prefetchedProjectsBlob = await projectsBlobPending
  392. Metrics.inc('project-list-prefetch-projects', 1, {
  393. status: prefetchedProjectsBlob ? 'success' : 'error',
  394. })
  395. // in v2 add notifications for matching university IPs
  396. if (Settings.overleaf != null && req.ip !== user.lastLoginIp) {
  397. try {
  398. await NotificationsBuilder.promises
  399. .ipMatcherAffiliation(user._id.toString())
  400. .create(req.ip)
  401. } catch (err) {
  402. logger.error(
  403. { err },
  404. 'failed to create institutional IP match notification'
  405. )
  406. }
  407. }
  408. const hasPaidAffiliation = userAffiliations.some(
  409. affiliation => affiliation.licence && affiliation.licence !== 'free'
  410. )
  411. const inactiveTutorials = TutorialHandler.getInactiveTutorials(user)
  412. const usGovBannerHooksResponse = await Modules.promises.hooks.fire(
  413. 'getUSGovBanner',
  414. userEmails,
  415. hasPaidAffiliation,
  416. inactiveTutorials
  417. )
  418. const usGovBanner = (usGovBannerHooksResponse &&
  419. usGovBannerHooksResponse[0]) || {
  420. showUSGovBanner: false,
  421. usGovBannerVariant: null,
  422. }
  423. const { showUSGovBanner, usGovBannerVariant } = usGovBanner
  424. const isUser30DaysOld = moment.utc().diff(user.signUpDate, 'days') > 30
  425. const showGroupsAndEnterpriseBanner =
  426. Features.hasFeature('saas') &&
  427. !showUSGovBanner &&
  428. !userIsMemberOfGroupSubscription &&
  429. !hasPaidAffiliation &&
  430. !inactiveTutorials.includes('groups-enterprise-banner-repeat') &&
  431. isUser30DaysOld
  432. const groupsAndEnterpriseBannerVariant =
  433. showGroupsAndEnterpriseBanner &&
  434. _.sample(['on-premise', 'FOMO', 'FOMO', 'FOMO'])
  435. let showInrGeoBanner = false
  436. let showLATAMBanner = false
  437. let recommendedCurrency
  438. let countryCode
  439. let currencyCode
  440. if (isSaas) {
  441. const currencyData = await GeoIpLookup.promises.getCurrencyCode(req.ip)
  442. countryCode = currencyData.countryCode
  443. currencyCode = currencyData.currencyCode
  444. }
  445. if (
  446. usersBestSubscription?.type === 'free' ||
  447. usersBestSubscription?.type === 'standalone-ai-add-on'
  448. ) {
  449. if (countryCode === 'IN') {
  450. showInrGeoBanner = true
  451. }
  452. showLATAMBanner =
  453. !!countryCode && ['MX', 'CO', 'CL', 'PE'].includes(countryCode)
  454. // LATAM Banner needs to know which currency to display
  455. if (showLATAMBanner) {
  456. recommendedCurrency = currencyCode
  457. }
  458. }
  459. let hasIndividualPaidSubscription = false
  460. try {
  461. hasIndividualPaidSubscription =
  462. SubscriptionHelper.isIndividualActivePaidSubscription(
  463. usersIndividualSubscription
  464. )
  465. } catch (error) {
  466. logger.error({ err: error }, 'Failed to get individual subscription')
  467. }
  468. const aiBlocked =
  469. Features.hasFeature('saas') && !(await _canUseAIAssist(user))
  470. const hasAiAssist =
  471. Features.hasFeature('saas') && (await _userHasAIAssist(user))
  472. const splitTests = [
  473. // Split tests that will be made available to the frontend
  474. 'import-docx',
  475. 'overleaf-library',
  476. 'import-markdown',
  477. ].filter(Boolean)
  478. await Promise.all(
  479. splitTests.map(splitTestName =>
  480. SplitTestHandler.promises.getAssignment(req, res, splitTestName)
  481. )
  482. )
  483. const userSettings = await UserSettingsHelper.buildUserSettings(
  484. req,
  485. res,
  486. user
  487. )
  488. let groupRole
  489. if (userIsMemberOfGroupSubscription) {
  490. const userIdStr = userId.toString()
  491. const isGroupAdmin = usersManagedGroupSubscriptions?.some(
  492. sub => sub.admin_id?._id?.toString() === userIdStr
  493. )
  494. const isGroupManager =
  495. usersManagedGroupSubscriptions?.length > 0 ||
  496. usersGroupSubscriptions?.some(sub => sub.userIsGroupManager)
  497. if (isGroupAdmin) {
  498. groupRole = 'admin'
  499. } else if (isGroupManager) {
  500. groupRole = 'manager'
  501. } else {
  502. groupRole = 'member'
  503. }
  504. }
  505. Modules.promises.hooks
  506. .fire('setUserProperties', userId, {
  507. overleaf_id: userId,
  508. last_active: user.lastActive
  509. ? Math.floor(user.lastActive.getTime() / 1000)
  510. : null,
  511. sign_up_date: user.signUpDate
  512. ? Math.floor(user.signUpDate.getTime() / 1000)
  513. : null,
  514. ...(usersBestSubscription?.type && {
  515. best_subscription_type: usersBestSubscription.type,
  516. }),
  517. ai_blocked: aiBlocked,
  518. has_ai_assist: hasAiAssist,
  519. ...(subjectArea && { subject_area: subjectArea }),
  520. ...(role && { role }),
  521. ...(primaryOccupation && { primary_occupation: primaryOccupation }),
  522. ...(usedLatex && { used_latex: usedLatex }),
  523. ...(countryCode && { country: countryCode }),
  524. ...(commonsInstitution && { commons_institution: commonsInstitution }),
  525. ...CustomerIoPlanHelpers.getAffiliationProperties(userEmails),
  526. ...(groupRole && { group_role: groupRole }),
  527. is_managed_user: Boolean(user.enrollment?.managedBy),
  528. ...(user.email && { email: user.email }),
  529. })
  530. .catch(err => {
  531. logger.error({ err }, 'Failed to set user properties for customer.io')
  532. })
  533. res.render('project/list-react', {
  534. title: 'your_projects',
  535. usersBestSubscription,
  536. notifications,
  537. notificationsInstitution,
  538. user,
  539. userAffiliations,
  540. userEmails,
  541. userSettings,
  542. reconfirmedViaSAML,
  543. allInReconfirmNotificationPeriods,
  544. survey,
  545. tags,
  546. portalTemplates,
  547. prefetchedProjectsBlob,
  548. showGroupsAndEnterpriseBanner,
  549. groupsAndEnterpriseBannerVariant,
  550. showUSGovBanner,
  551. usGovBannerVariant,
  552. showLATAMBanner,
  553. recommendedCurrency,
  554. showInrGeoBanner,
  555. projectDashboardReact: true, // used in navbar
  556. groupSsoSetupSuccess,
  557. joinedGroupName,
  558. viaDomainCapture,
  559. groupSubscriptionsPendingEnrollment:
  560. groupSubscriptionsPendingEnrollment.map(subscription => ({
  561. groupId: subscription._id,
  562. groupName: subscription.teamName,
  563. })),
  564. hasIndividualPaidSubscription,
  565. userRestrictions: Array.from(req.userRestrictions || []),
  566. customerIoEnabled,
  567. inactiveTutorials,
  568. })
  569. }
  570. /**
  571. * Load user's projects with pagination, sorting and filters
  572. *
  573. * @param {GetProjectsRequest} req the request
  574. * @param {GetProjectsResponse} res the response
  575. * @returns {Promise<void>}
  576. */
  577. async function getProjectsJson(req, res) {
  578. const { filters, page, sort } = req.body
  579. const userId = SessionManager.getLoggedInUserId(req.session)
  580. const projectsPage = await _getProjects(userId, filters, sort, page)
  581. res.json(projectsPage)
  582. }
  583. /**
  584. * @param {string} userId
  585. * @private
  586. */
  587. async function _checkForOldDebugProjects(userId) {
  588. const exists = await ProjectGetter.promises.existUsersDebugProjectsOlderThan(
  589. userId,
  590. 7
  591. )
  592. if (exists) {
  593. await NotificationsBuilder.promises.oldDebugProjects(userId).create()
  594. } else {
  595. await NotificationsBuilder.promises.oldDebugProjects(userId).read()
  596. }
  597. }
  598. /**
  599. * @param {string} userId
  600. * @param {Filters} filters
  601. * @param {Sort} sort
  602. * @param {Page} page
  603. * @returns {Promise<{totalSize: number, projects: Project[]}>}
  604. * @private
  605. */
  606. async function _getProjects(
  607. userId,
  608. filters = {},
  609. sort = { by: 'lastUpdated', order: 'desc' },
  610. page = { size: 20 }
  611. ) {
  612. /** @type {[AllUsersProjects, MongoTag[]]} */
  613. const results = await Promise.all([
  614. ProjectGetter.promises.findAllUsersProjects(
  615. userId,
  616. 'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens'
  617. ),
  618. TagsHandler.promises.getAllTags(userId),
  619. ])
  620. const [allProjects, tags] = results
  621. const formattedProjects = _formatProjects(allProjects, userId)
  622. const filteredProjects = _applyFilters(
  623. formattedProjects,
  624. tags,
  625. filters,
  626. userId
  627. )
  628. const pagedProjects = _sortAndPaginate(filteredProjects, sort, page)
  629. const projects = await _injectProjectUsers(pagedProjects)
  630. return {
  631. totalSize: filteredProjects.length,
  632. projects,
  633. }
  634. }
  635. /**
  636. * @param {AllUsersProjects} projects
  637. * @param {string} userId
  638. * @returns {FormattedProject[]}
  639. * @private
  640. */
  641. function _formatProjects(projects, userId) {
  642. const {
  643. owned,
  644. review,
  645. readAndWrite,
  646. readOnly,
  647. tokenReadAndWrite,
  648. tokenReadOnly,
  649. } = projects
  650. const formattedProjects = /** @type {FormattedProject[]} **/ []
  651. for (const project of owned) {
  652. formattedProjects.push(
  653. _formatProjectInfo(project, 'owner', Sources.OWNER, userId)
  654. )
  655. }
  656. // Invite-access
  657. for (const project of readAndWrite) {
  658. formattedProjects.push(
  659. _formatProjectInfo(project, 'readWrite', Sources.INVITE, userId)
  660. )
  661. }
  662. for (const project of review) {
  663. formattedProjects.push(
  664. _formatProjectInfo(project, 'review', Sources.INVITE, userId)
  665. )
  666. }
  667. for (const project of readOnly) {
  668. formattedProjects.push(
  669. _formatProjectInfo(project, 'readOnly', Sources.INVITE, userId)
  670. )
  671. }
  672. // Token-access
  673. // Only add these formattedProjects if they're not already present, this gives us cascading access
  674. // from 'owner' => 'token-read-only'
  675. for (const project of tokenReadAndWrite) {
  676. if (!formattedProjects.some(p => p.id === project._id.toString())) {
  677. formattedProjects.push(
  678. _formatProjectInfo(project, 'readAndWrite', Sources.TOKEN, userId)
  679. )
  680. }
  681. }
  682. for (const project of tokenReadOnly) {
  683. if (!formattedProjects.some(p => p.id === project._id.toString())) {
  684. formattedProjects.push(
  685. _formatProjectInfo(project, 'readOnly', Sources.TOKEN, userId)
  686. )
  687. }
  688. }
  689. return formattedProjects
  690. }
  691. /**
  692. * @param {FormattedProject[]} projects
  693. * @param {MongoTag[]} tags
  694. * @param {Filters} filters
  695. * @param {string} userId
  696. * @returns {FormattedProject[]}
  697. * @private
  698. */
  699. function _applyFilters(projects, tags, filters, userId) {
  700. if (!_hasActiveFilter(filters)) {
  701. return projects
  702. }
  703. return projects.filter(project => _matchesFilters(project, tags, filters))
  704. }
  705. /**
  706. * @param {FormattedProject[]} projects
  707. * @param {Sort} sort
  708. * @param {Page} page
  709. * @returns {FormattedProject[]}
  710. * @private
  711. */
  712. function _sortAndPaginate(projects, sort, page) {
  713. if (
  714. (sort.by && !['lastUpdated', 'title', 'owner'].includes(sort.by)) ||
  715. (sort.order && !['asc', 'desc'].includes(sort.order))
  716. ) {
  717. throw new OError('Invalid sorting criteria', { sort })
  718. }
  719. const sortedProjects = _.orderBy(
  720. projects,
  721. [sort.by || 'lastUpdated'],
  722. [sort.order || 'desc']
  723. )
  724. // TODO handle pagination
  725. return sortedProjects
  726. }
  727. /**
  728. * @param {MongoProject} project
  729. * @param {ProjectAccessLevel} accessLevel
  730. * @param {Source} source
  731. * @param {string} userId
  732. * @returns {FormattedProject}
  733. * @private
  734. */
  735. function _formatProjectInfo(project, accessLevel, source, userId) {
  736. const archived = ProjectHelper.isArchived(project, userId)
  737. // If a project is simultaneously trashed and archived, we will consider it archived but not trashed.
  738. const trashed = ProjectHelper.isTrashed(project, userId) && !archived
  739. const readOnlyTokenAccess =
  740. accessLevel === PrivilegeLevels.READ_ONLY && source === Sources.TOKEN
  741. return {
  742. id: project._id.toString(),
  743. name: project.name,
  744. owner_ref: readOnlyTokenAccess ? null : project.owner_ref,
  745. lastUpdated: project.lastUpdated,
  746. lastUpdatedBy: readOnlyTokenAccess ? null : project.lastUpdatedBy,
  747. accessLevel,
  748. source,
  749. archived,
  750. trashed,
  751. }
  752. }
  753. /**
  754. * @param {FormattedProject[]} projects
  755. * @returns {Promise<Project[]>}
  756. * @private
  757. */
  758. async function _injectProjectUsers(projects) {
  759. const userIds = new Set()
  760. for (const project of projects) {
  761. if (project.owner_ref != null) {
  762. userIds.add(project.owner_ref.toString())
  763. }
  764. if (project.lastUpdatedBy != null) {
  765. userIds.add(project.lastUpdatedBy.toString())
  766. }
  767. }
  768. const projection = {
  769. first_name: 1,
  770. last_name: 1,
  771. email: 1,
  772. }
  773. /** @type {Record<string, UserRef>} */
  774. const users = {}
  775. for (const user of await UserGetter.promises.getUsers(userIds, projection)) {
  776. const userId = user._id.toString()
  777. users[userId] = {
  778. id: userId,
  779. email: user.email,
  780. firstName: user.first_name,
  781. lastName: user.last_name,
  782. }
  783. }
  784. return projects.map(project => ({
  785. id: project.id,
  786. name: project.name,
  787. archived: project.archived,
  788. trashed: project.trashed,
  789. accessLevel: project.accessLevel,
  790. source: project.source,
  791. lastUpdated: project.lastUpdated.toISOString(),
  792. lastUpdatedBy:
  793. project.lastUpdatedBy == null
  794. ? null
  795. : users[project.lastUpdatedBy.toString()] || null,
  796. owner:
  797. project.owner_ref == null
  798. ? undefined
  799. : users[project.owner_ref.toString()],
  800. owner_ref: undefined,
  801. }))
  802. }
  803. /**
  804. * @param {any} project
  805. * @param {MongoTag[]} tags
  806. * @param {Filters} filters
  807. * @private
  808. */
  809. function _matchesFilters(project, tags, filters) {
  810. if (filters.ownedByUser && project.accessLevel !== 'owner') {
  811. return false
  812. }
  813. if (filters.sharedWithUser && project.accessLevel === 'owner') {
  814. return false
  815. }
  816. if (filters.archived && !project.archived) {
  817. return false
  818. }
  819. if (filters.trashed && !project.trashed) {
  820. return false
  821. }
  822. if (
  823. filters.tag &&
  824. !_.find(
  825. tags,
  826. tag =>
  827. filters.tag === tag.name && (tag.project_ids || []).includes(project.id)
  828. )
  829. ) {
  830. return false
  831. }
  832. if (
  833. filters.search?.length &&
  834. project.name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1
  835. ) {
  836. return false
  837. }
  838. return true
  839. }
  840. /**
  841. * @param {Filters} filters
  842. * @returns {boolean}
  843. * @private
  844. */
  845. function _hasActiveFilter(filters) {
  846. return Boolean(
  847. filters.ownedByUser ||
  848. filters.sharedWithUser ||
  849. filters.archived ||
  850. filters.trashed ||
  851. filters.tag === null ||
  852. filters.tag?.length ||
  853. filters.search?.length
  854. )
  855. }
  856. /**
  857. * @param {any} user
  858. */
  859. // todo: quota clean-up: rename function and vars
  860. async function _userHasAIAssist(user) {
  861. let hasPremiumAiFeatures
  862. const inQuotaSplitTest =
  863. await SplitTestHandler.promises.featureFlagEnabledForUser(
  864. user._id,
  865. 'plans-2026-phase-1'
  866. )
  867. if (inQuotaSplitTest) {
  868. hasPremiumAiFeatures =
  869. user.features?.aiUsageQuota === Settings.aiFeatures.unlimitedQuota
  870. } else {
  871. hasPremiumAiFeatures = user.features?.aiErrorAssistant === true
  872. }
  873. // Check if the user has a non free trial version of our AI features
  874. if (hasPremiumAiFeatures) {
  875. return true
  876. }
  877. // Check if the user has AI Assist enabled via Writefull
  878. const { isPremium: hasAiAssistViaWritefull } =
  879. await UserGetter.promises.getWritefullData(user._id)
  880. if (hasAiAssistViaWritefull) {
  881. return true
  882. }
  883. return false
  884. }
  885. // Determines if user is able to enable AI assist
  886. // based on their permissions and settings
  887. // It does NOT determine if the user has AI Assist enabled
  888. /**
  889. * @param {any} user
  890. */
  891. async function _canUseAIAssist(user) {
  892. // Check if the assistant has been manually disabled by the user
  893. // post https://github.com/overleaf/internal/pull/31273 we can rely on user.aiFeatures being populated
  894. if (user.aiFeatures?.enabled === false) {
  895. return false
  896. }
  897. // Check if the user can use AI features (policy check)
  898. return await PermissionsManager.promises.checkUserPermissions(user, [
  899. 'use-ai',
  900. ])
  901. }
  902. export default {
  903. projectListPage: expressify(projectListPage),
  904. getProjectsJson: expressify(getProjectsJson),
  905. }