ProjectListController.mjs 29 KB

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